@vibelord/lanes 1.2.0 → 1.5.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 (46) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +7 -5
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +1091 -76
  5. package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
  6. package/payload/lanes/container/hooks/grok-hooks.json +12 -0
  7. package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
  8. package/payload/lanes/container/lane +55 -8
  9. package/payload/lanes/container/lane-mcp +120 -22
  10. package/payload/lanes/container/lane-signal +86 -23
  11. package/payload/lanes/container/lane_core.py +524 -36
  12. package/payload/lanes/container/throttle-pulse +456 -0
  13. package/payload/lanes/docs/ARCHITECTURE.md +357 -34
  14. package/payload/lanes/docs/DESIGN.md +13 -3
  15. package/payload/lanes/docs/INSTALL.md +47 -10
  16. package/payload/lanes/docs/RUNBOOK.md +761 -12
  17. package/payload/lanes/docs/USERGUIDE.md +421 -46
  18. package/payload/lanes/holders.lock +5 -0
  19. package/payload/lanes/install/enable-docs.sh +78 -0
  20. package/payload/lanes/install/host-install.sh +39 -5
  21. package/payload/lanes/libexec/agent_state.py +604 -12
  22. package/payload/lanes/libexec/docs_store.py +995 -0
  23. package/payload/lanes/libexec/docsd.py +2010 -0
  24. package/payload/lanes/libexec/economy_poll.py +6 -2
  25. package/payload/lanes/libexec/harness_grok.py +742 -0
  26. package/payload/lanes/libexec/lanes_lib.py +143 -20
  27. package/payload/lanes/libexec/mdlite.py +575 -0
  28. package/payload/lanes/libexec/members.py +5 -0
  29. package/payload/lanes/libexec/notify.py +65 -5
  30. package/payload/lanes/libexec/sidebar.py +626 -25
  31. package/payload/lanes/libexec/statusd.py +530 -32
  32. package/payload/lanes/libexec/tokens.py +42 -9
  33. package/payload/lanes/libexec/update.py +171 -26
  34. package/payload/lanes/libexec/uploads.py +538 -0
  35. package/payload/lanes/libexec/users.py +31 -3
  36. package/payload/lanes/site/site.example.conf +4 -0
  37. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  38. package/payload/lanes/web/Caddyfile +24 -1
  39. package/payload/lanes/web/README.md +86 -8
  40. package/payload/lanes/web/browser.js +2 -4
  41. package/payload/lanes/web/docs.css +52 -0
  42. package/payload/lanes/web/docs.js +36 -0
  43. package/payload/lanes/web/mobile.js +3 -2
  44. package/payload/lanes/web/upload.css +17 -0
  45. package/payload/lanes/web/upload.js +84 -0
  46. package/payload/manifest.json +5 -3
@@ -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) · i folds / unfolds the fleet block (W11)
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,16 @@ 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.
55
+ Fleet block (W11, spec 2026-10-11 §3): pinned above the upload strip, from status.json's `fleet` and `throttle` objects
56
+ only — Throttle in one row, an availability bar per provider (Anthropic, OpenAI and, since W12, Moonshot's Kimi),
57
+ the host and its top containers; a pane of 25 rows or more draws a rule and six rows, 18–24 three, fewer none, and the
58
+ lane list scrolls in the rest. `i` folds it to one line in the owner's profile (`look.fleet_collapsed`); with
59
+ LANES_FLEET_DASHBOARD_URL in site.conf the host row is one OSC 8 link to the dashboard. While its unfolded Throttle
60
+ row draws the money and every part after it (the same facts), the strip row's economy line yields to it; a row that
61
+ dropped `spill` or `429` to fit keeps the strip.
51
62
  Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
52
63
  when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
53
64
  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 +77,7 @@ import sys
66
77
  import termios
67
78
  import threading
68
79
  import time
80
+ import urllib.parse
69
81
  from operator import itemgetter
70
82
  from pathlib import Path
71
83
  import tty
@@ -184,6 +196,35 @@ PROFILE_LOCK_S = 0.2
184
196
  PROFILE_RETRY_S = 5.0
185
197
  # 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
198
  NO_PROFILE = 'no owner: lanes device claim'
199
+ # The docs cockpit (plan W10 W7, LNS-108). The docs page is lanes-docsd behind Caddy's `handle_path /lanes-docs/*`; a
200
+ # lane's documents are its index filtered `?ws=<ws>&lane=<lane>`. statusd publishes each lane's `docs: {n, at}` and the
201
+ # document's `web_origin` (`https://<name>:8443`) in status.json, which this sidebar already reads — no stat, no fork
202
+ # of its own. The links are OSC 8 hyperlinks (bytes, never cells): the upload strip's ` ↑ uploads · ▤ docs N (U)` to
203
+ # the page, the cursor row's name to its lane's documents, and the footer's `d` message to the same. Without an origin
204
+ # there is no link (a terminal needs an absolute URL) and `d` says the path alone. The origin is checked again here: it
205
+ # rides inside an OSC string, so only `https://<DNS host name>:8443` passes.
206
+ DOCS_PATH = '/lanes-docs/'
207
+ WEB_ORIGIN = re.compile(r'https://[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?:8443\Z', re.ASCII)
208
+
209
+
210
+ def docs_count(row):
211
+ """A status.json lane row's document count (`docs.n`, plan W10 W7): a positive int, else 0."""
212
+ docs = row.get('docs') if isinstance(row, dict) else None
213
+ n = docs.get('n') if isinstance(docs, dict) else None
214
+ return n if isinstance(n, int) and not isinstance(n, bool) and n > 0 else 0
215
+
216
+
217
+ def docs_url(origin, ws=None, lane=None):
218
+ """The docs page on `origin` ('' = the path alone), filtered to a workspace and a lane when given:
219
+ `/lanes-docs/?ws=<ws>&lane=<lane>`, each value percent-quoted (nothing in it can end an OSC string)."""
220
+ query = '&'.join(f'{key}={urllib.parse.quote(value, safe="")}' for key, value in (('ws', ws), ('lane', lane)) if value)
221
+ return f'{origin}{DOCS_PATH}' + (f'?{query}' if query else '')
222
+
223
+
224
+ def osc8(url, text):
225
+ """`text` as ONE OSC 8 hyperlink to `url` (ST-terminated, no id); `text` as it is when there is no url. Costs
226
+ len(url) + 14 bytes and no cell: the budget gate's strip and cell walk read through it."""
227
+ return f'\033]8;;{url}\033\\{text}\033]8;;\033\\' if url else text
187
228
 
188
229
 
189
230
  def shown_version(path):
@@ -267,6 +308,154 @@ def load_economy(doc):
267
308
  lanes={k: v for k, v in lanes.items() if isinstance(v, dict)} if isinstance(lanes, dict) else {})
268
309
 
269
310
 
311
+ # ----------------------------------------------------------------- the fleet block (W11, spec 2026-10-11 §3)
312
+ # Pinned above the upload strip: Throttle in one line, an availability bar per provider, the host and its top
313
+ # containers — from status.json's `fleet` and `throttle` objects ONLY (statusd publishes both, spec §2.1/§2.2), so the
314
+ # block costs no stat, read or fork of the sidebar's own on a tick. A pane of FLEET_FULL rows or more draws a rule and
315
+ # six rows (W12 added the Kimi row: FLEET_FULL 24 -> 25, so the body a full block leaves keeps its W11 floor), one of
316
+ # FLEET_SHORT..FLEET_FULL − 1 rows three (Throttle, the worse provider, the host), a shorter one none; `i` folds it to
317
+ # one line (`look.fleet_collapsed` in the owner's profile). The lane list scrolls in the rest.
318
+ FLEET_SCHEMA = 1 # the `v` of both objects this reader understands (absent = 1; another is ignored)
319
+ FLEET_FULL, FLEET_SHORT = 25, 18
320
+ # Own-clock floors beneath the publishers' `stale` flags (DEF-1528's rule, as ECONOMY_STALE): statusd writes `fleet`
321
+ # every 10 s (a 12 s cycle at its slowest) and the pulse `throttle.at` at least every 60 s, which statusd flags past
322
+ # 60 s; a status.json that stopped changing (statusd frozen) still turns the block stale here.
323
+ FLEET_STALE_S, PULSE_STALE_S = 60, 90
324
+ BAR_CELLS = 10
325
+ # The pulse's pools, in row order, and the row's word: the vendor (W12, spec 2026-10-15 §6: Kimi Code's pool is
326
+ # `kimi`, its vendor Moonshot AI). No grok row until Throttle has an xAI pool (the 2026-10-14 xAI pool review's gates).
327
+ PROVIDERS = (('claude', 'anthropic'), ('codex', 'openai'), ('kimi', 'moonshot'))
328
+ # The pools whose overflow is money (fleet_money): Kimi has no paid overflow lane, so a kimi figure is never shown —
329
+ # the pulse records it as nulls (throttle-pulse MONEY_POOLS), and this reader would not show one if it did not.
330
+ MONEY_POOLS = ('claude', 'codex')
331
+ LABEL_CELLS = 9 # the label field of the Throttle and provider rows (`anthropic`), then one blank
332
+ # `429 +N`: how much the pools' upstream_429_total grew over the last SURGE_S seconds of pulse samples this sidebar
333
+ # saw — from the newest sample at or before that window's start; a counter that went down (a daemon restart) starts
334
+ # again. At most SURGE_SAMPLES are kept (the pulse writes at most every 10 s: 31 in five minutes).
335
+ SURGE_S, SURGE_SAMPLES = 300, 64
336
+ CPU_ATTENTION, MEM_ATTENTION = 0.95, 0.90
337
+ GIB = 1 << 30
338
+ SEVERITY = {'tone-urgent': 2, 'tone-attention': 1} # a row's worst part tones its lead mark; the fold's verdict
339
+ BAR_MARKS = ('bar-usable', 'bar-cooling', 'bar-benched', 'bar-dead')
340
+ # LANES_FLEET_DASHBOARD_URL, the operator's site.conf value (unset until an authenticated dashboard exists): the host
341
+ # row is then ONE OSC 8 link to it. Read at start and with the 30 s version recheck (recheck_version), never on a tick:
342
+ # the line as bin/lanes reads site.conf (SITE_LINE's characters, the last line wins), and only an https URL passes —
343
+ # it rides inside an OSC string, and a terminal needs an absolute URL.
344
+ SITE_CONF = os.path.join(os.path.expanduser('~'), '.config', 'lanes', 'site.conf')
345
+ SITE_CONF_MAX = 64 * 1024
346
+ DASHBOARD_LINE = re.compile(r'LANES_FLEET_DASHBOARD_URL=([A-Za-z0-9_.:@/-]*)')
347
+ DASHBOARD_URL = re.compile(r'https://[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?(?::[0-9]{1,5})?(?:/[A-Za-z0-9_.:@/-]*)?\Z',
348
+ re.ASCII)
349
+
350
+
351
+ def fleet_doc(value):
352
+ """status.json's `fleet` / `throttle` object as the block reads it: a dict whose `v` is 1 (or absent), else {} —
353
+ an object of another version is ignored rather than half-read (load_economy's rule)."""
354
+ return value if isinstance(value, dict) and value.get('v', FLEET_SCHEMA) == FLEET_SCHEMA else {}
355
+
356
+
357
+ def tally(value):
358
+ """A pool count: a non-negative int (a finite float is truncated), else 0."""
359
+ value = finite(value)
360
+ return max(0, int(value)) if value is not None else 0
361
+
362
+
363
+ def half_up(numerator, denominator):
364
+ """numerator / denominator (both ≥ 0, denominator > 0) rounded half up, in integers — never Python's half-to-even
365
+ (2.5 cells down, 3.5 up) and never a float that lands a hair under .5 (7 × 10 / 14 must give 5, not 4)."""
366
+ return (2 * numerator + denominator) // (2 * denominator)
367
+
368
+
369
+ def bar_segments(pool):
370
+ """(usable, cooling, benched, dead) accounts of a pulse pool, exclusive and summing to its `configured`; None
371
+ without a configured count. The pulse's buckets overlap (spec §2.1: `cooling` is its remainder, `benched` the
372
+ model-benched accounts, usable or not), so the bar takes them in order, each capped by what is left: usable;
373
+ cooling; benched WITHIN cooling — a benched account the pool still counts usable reads usable, one it does not
374
+ reads benched instead of cooling; dead the rest (exhausted, auth failed, disabled, billing- or organization-blocked
375
+ or unhealthy: configured − usable − cooling)."""
376
+ configured = finite(pool.get('configured')) if isinstance(pool, dict) else None
377
+ if configured is None:
378
+ return None
379
+ configured = max(0, int(configured))
380
+ usable = min(tally(pool.get('usable')), configured)
381
+ cooling = min(tally(pool.get('cooling')), configured - usable)
382
+ benched = min(tally(pool.get('benched')), cooling)
383
+ return usable, cooling - benched, benched, configured - usable - cooling
384
+
385
+
386
+ def bar_cells(segments):
387
+ """Cells per segment (usable, cooling, benched, dead) of the BAR_CELLS-cell bar; they always sum to BAR_CELLS.
388
+ Usable is `usable × 10 / configured` rounded half up and held to 1..9 while some but not all accounts are usable:
389
+ the bar never shows none of a pool that serves, nor all of one that does not. The other three share what is left
390
+ by cumulative half-up rounding over the accounts that are not usable. Nothing configured is ten dead cells."""
391
+ usable, *rest = segments
392
+ configured = usable + sum(rest)
393
+ if not configured:
394
+ return (0, 0, 0, BAR_CELLS)
395
+ cells = half_up(usable * BAR_CELLS, configured)
396
+ if 0 < usable < configured:
397
+ cells = min(BAR_CELLS - 1, max(1, cells))
398
+ left, others = BAR_CELLS - cells, configured - usable
399
+ out, edge_before, seen = [cells], 0, 0
400
+ for value in rest:
401
+ seen += value
402
+ edge = half_up(seen * left, others) if others else 0
403
+ out.append(edge - edge_before)
404
+ edge_before = edge
405
+ return tuple(out)
406
+
407
+
408
+ def compact_gib(value):
409
+ """Bytes as GiB in at most four cells: 0.4G, 8.4G, 14G, 123G (no trailing `.0`)."""
410
+ gib = max(0.0, float(value)) / GIB
411
+ return (f'{gib:.1f}' if gib < 9.95 else f'{gib:.0f}').removesuffix('.0') + 'G'
412
+
413
+
414
+ def aged(doc, now, floor):
415
+ """Whether a `fleet` / `throttle` object is a frozen picture: its publisher says so (`stale: true`), or its own `at`
416
+ is more than `floor` seconds behind `now` (OUR clock, DEF-1528). False for {} (nothing known, nothing claimed) and
417
+ without a numeric `at`."""
418
+ if not doc:
419
+ return False
420
+ at = number(doc.get('at'))
421
+ return doc.get('stale') is True or (at is not None and now - at > floor)
422
+
423
+
424
+ def finite(value):
425
+ """number(value) when it is also finite (a NaN or an infinity in published JSON is no fact), else None."""
426
+ value = number(value)
427
+ return value if value is not None and -INF < value < INF else None
428
+
429
+
430
+ def whole(value):
431
+ """A finite value ≥ 0 rounded half up to an int (tenths first, so 37.25 is 37 and 2.5 is 3); held to 0..10⁹, so
432
+ no published figure can overflow a frame or a float."""
433
+ return half_up(round(min(max(value, 0.0), 1e9) * 10), 10)
434
+
435
+
436
+ def percent(fraction):
437
+ """A finite 0–1 fraction as a whole percent (`53`), half up, never below 0."""
438
+ return whole(fraction * 100) if fraction < 1e7 else whole(1e9)
439
+
440
+
441
+ def short_name(value, cells):
442
+ """A name from status.json as a row prints it: printable (control and format characters dropped — it is
443
+ published data, never an escape), at most `cells` cells; '' when it is no string."""
444
+ return clip(OSC_UNSAFE.sub('', value), cells)[0] if isinstance(value, str) else ''
445
+
446
+
447
+ def site_dashboard(path=None):
448
+ """LANES_FLEET_DASHBOARD_URL from the server's site.conf (read, never executed; at most SITE_CONF_MAX bytes): ''
449
+ when the file or the line is absent, unreadable, or the value is not an https URL of DNS-name characters."""
450
+ try:
451
+ with open(path or SITE_CONF, 'rb') as handle:
452
+ text = handle.read(SITE_CONF_MAX).decode('utf-8', 'replace')
453
+ except OSError:
454
+ return ''
455
+ found = [match.group(1) for match in map(DASHBOARD_LINE.fullmatch, text.splitlines()) if match]
456
+ return found[-1] if found and DASHBOARD_URL.match(found[-1]) else ''
457
+
458
+
270
459
  # The six words statusd publishes (alignment contract A3) and the helper word for an `exec` lane. There is no
271
460
  # seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823). Values are
272
461
  # (unicode, ascii, tone) from the one token source — the tone is a role name from tokens.ROLES, never an SGR code:
@@ -390,6 +579,18 @@ def mtime(path):
390
579
  return 0
391
580
 
392
581
 
582
+ def file_stamp(path):
583
+ """(st_ino, st_mtime_ns, st_size) of `path`, or 0 when it cannot be stat()ed. For a file replaced by rename
584
+ (users.update -> atomic_write: a new temp file, created while the old one still exists, then renamed over it) every
585
+ write has a new inode, so two writes inside one timestamp tick of the file system still differ — an mtime alone
586
+ reads them as one (DEF-1972)."""
587
+ try:
588
+ info = os.stat(path)
589
+ except OSError:
590
+ return 0
591
+ return info.st_ino, info.st_mtime_ns, info.st_size
592
+
593
+
393
594
  def cell_width(text):
394
595
  if text.isascii(): # every ASCII character is one cell here (none combines, none is wide): the common case
395
596
  return len(text)
@@ -455,16 +656,17 @@ BOX_ROWS = 1 # the upload strip (spec §4.6(d)); it was a four-row box
455
656
  # config.kdl's shared block; lane chords such as Ctrl-Alt-m are no sidebar keys and stay off). 18 lines below the
456
657
  # dynamic version row (cockpit v3 V2, LNS-84: ` lanes <current> · view <tree>`, key_sheet) — exactly the 19-row body
457
658
  # 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.
659
+ # `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,
660
+ # and W11's `i` (fold the fleet block) the reload and the sheet, `r i ?`.
459
661
  # test_sidebar.DocumentedKeys holds the sheet, the table and handle() to the same keys.
460
662
  KEY_SHEET = (('↑ ↓ j k', 'Up Dn j k', 'move'), ('⏎ → Space', 'Enter Spc', 'open the cursor lane'),
461
663
  ('J K', 'J K', 'open next / prev lane'), ('Tab S-Tab', 'Tab S-Tab', 'next / prev needing you'),
462
664
  ('2 3', '2 3', 'show in viewport 2 / 3'), ('/', '/', 'find a lane'),
463
665
  ('Esc', 'Esc', 'cancel, back to lane'), ('g G', 'g G', 'first / last'),
464
- ('+ U', '+ U', 'new shell / upload'), ('a', 'a', 'add to a workspace'),
666
+ ('+ U d', '+ U d', 'shell / upload / docs'), ('a', 'a', 'add to a workspace'),
465
667
  ('x', 'x', 'close (y, then Enter)'), ('u z', 'u z', 'unregister / undo 10 s'),
466
668
  ('R', 'R', 'rename'), ('P', 'P', 'register a session'), ('p h', 'p h', 'pin / hide lane'),
467
- ('H o', 'H o', 'hide tab / hidden lanes'), ('r ?', 'r ?', 'reload / this sheet'),
669
+ ('H o', 'H o', 'hide tab / hidden lanes'), ('r i ?', 'r i ?', 'reload / fleet / keys'),
468
670
  ('Ctrl-Alt-l', 'Ctrl-Alt-l', 'focus the sidebar'))
469
671
  PASTE_START, PASTE_END = '\033[200~', '\033[201~'
470
672
  # A paste typed into a footer field (Sidebar.pasted): a run of line breaks / tabs is one space, never an Enter.
@@ -649,6 +851,18 @@ class Sidebar:
649
851
  # W9 U6: whether clients/<device>.update-ready existed at the last look (poll_update), when to look next, and until
650
852
  # when a rebuild `r` started counts as on its way (monotonic seconds)
651
853
  update_ready, update_poll_at, rebuild_until = False, 0.0, 0.0
854
+ # The docs cockpit (plan W10 W7): status.json's `web_origin` ('' = none known, so no link), this workspace's
855
+ # document count (reload), the link of the footer message `d` set ('' = the message is no link; the message
856
+ # setter clears it) and the link the footer row draws this frame (footer_text sets it).
857
+ web_origin, docs_total, _message_link, footer_link = '', 0, '', ''
858
+ # The fleet block (W11; read-only defaults, every write assigns fresh values): status.json's `fleet` and `throttle`
859
+ # (take_fleet), `i`'s fold (the owner's profile look.fleet_collapsed; this process's own on a device nobody owns),
860
+ # the dashboard link (site.conf, fleet_url), the (pulse at, 429 total) samples `429 +N` is measured over, the
861
+ # block's place in the last frame (`block_top` its first 1-based row, 0 = not drawn; `block_rows` how many) and the
862
+ # stale verdicts poll_fleet last saw (fleet, throttle).
863
+ fleet_facts, throttle_facts, fleet_collapsed, fleet_url = {}, {}, False, ''
864
+ surge_samples, block_top, block_rows, fleet_frozen = (), 0, 0, (False, False)
865
+ block_says_money = False # fleet_block: the unfolded Throttle row drew the money and every part after it
652
866
 
653
867
  def __init__(self, device, workspace, slot='main', viewports='1'):
654
868
  self.device, self.workspace, self.slot = device, workspace, slot
@@ -726,6 +940,7 @@ class Sidebar:
726
940
  self.current_version = (self.tree_version if self.current_path == str(L.REPO)
727
941
  else shown_version(self.current_path))
728
942
  self.version_at = time.monotonic()
943
+ self.fleet_url = site_dashboard() # W11: LANES_FLEET_DASHBOARD_URL, again with every version recheck
729
944
  # A predecessor that swapped itself out left its cursor, scroll, seen marks and filter (cockpit v3 U5).
730
945
  self.swappable, self.restore_id = False, None
731
946
  self.restored = self.take_state()
@@ -775,9 +990,13 @@ class Sidebar:
775
990
  self.status = status.get('lanes', {})
776
991
  self.unregistered = (status.get('unregistered') or {}).get(self.workspace, [])
777
992
  self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
993
+ origin = status.get('web_origin')
994
+ self.web_origin = origin if isinstance(origin, str) and WEB_ORIGIN.match(origin) else ''
995
+ self.take_fleet(status if isinstance(status, dict) else {}) # W11: the fleet block's facts
778
996
  self.keep_moved_row()
779
997
  if stamp[0] != self.stamp[0] or stamp[1] != self.stamp[1] or force:
780
998
  self.fleet = self.fleet_counts()
999
+ self.docs_total = self.workspace_docs()
781
1000
  look = stamp[3] != self.stamp[3] or force
782
1001
  profile = stamp[6] != self.stamp[6] or force
783
1002
  if look:
@@ -839,6 +1058,9 @@ class Sidebar:
839
1058
  if now - self.version_at < VERSION_RECHECK_S:
840
1059
  return False
841
1060
  self.version_at = now
1061
+ url = site_dashboard() # W11: the dashboard link follows site.conf within 30 s
1062
+ if url != self.fleet_url:
1063
+ self.fleet_url, self.dirty = url, True
842
1064
  if self.current_path == str(L.REPO):
843
1065
  return False
844
1066
  version = shown_version(self.current_path)
@@ -958,8 +1180,9 @@ class Sidebar:
958
1180
  return None, None
959
1181
 
960
1182
  def profile_stamp(self):
961
- """reload()'s seventh stamp field: the profile's mtime — 0, with no stat at all, on a device nobody owns."""
962
- return mtime(self.profile_path) if self.profile_path else 0
1183
+ """reload()'s seventh stamp field: the profile's (inode, mtime, size) (`file_stamp`: two writes in one mtime
1184
+ tick are both seen, DEF-1972) — 0, with no stat at all, on a device nobody owns."""
1185
+ return file_stamp(self.profile_path) if self.profile_path else 0
963
1186
 
964
1187
  def read_profile(self):
965
1188
  """The person's profile as users.read normalizes it (None: no owner, no file yet, or a file this release does
@@ -967,6 +1190,8 @@ class Sidebar:
967
1190
  profile = users.read(self.login, os.path.dirname(self.profile_path)) if self.profile_path else None
968
1191
  self.profile_look = dict(profile['look']) if profile else {}
969
1192
  self.take_arrangement(profile)
1193
+ if self.profile_path: # W11 `i`: the owner's fold; a device nobody owns keeps its own
1194
+ self.fleet_collapsed = users.look_flag(profile, 'fleet_collapsed')
970
1195
  return profile
971
1196
 
972
1197
  def take_arrangement(self, profile):
@@ -1418,6 +1643,312 @@ class Sidebar:
1418
1643
  lead_painted = f'{tone["ink-dim"]}{lead}{tone["reset"]}' if stale else lead
1419
1644
  return lead_painted + sep_painted.join(painted), lead + sep_plain.join(plain)
1420
1645
 
1646
+ # ---------------------------------------------------------- the fleet block (W11, spec 2026-10-11 §3)
1647
+ def take_fleet(self, status):
1648
+ """reload()'s status.json read: its `fleet` and `throttle` objects (fleet_doc) and, once per new pulse sample
1649
+ (`throttle.at` moved), the pools' summed `upstream_429_total` for `429 +N` (surge). Fresh values only."""
1650
+ self.fleet_facts = fleet_doc(status.get('fleet'))
1651
+ self.throttle_facts = fleet_doc(status.get('throttle'))
1652
+ at = finite(self.throttle_facts.get('at'))
1653
+ pools = self.throttle_facts.get('pools')
1654
+ totals = [finite(pool.get('upstream_429_total')) for pool in pools.values() if isinstance(pool, dict)] \
1655
+ if isinstance(pools, dict) else []
1656
+ totals = [total for total in totals if total is not None]
1657
+ if at is None or not totals:
1658
+ return
1659
+ total, samples = sum(totals), self.surge_samples
1660
+ if samples and at <= samples[-1][0]:
1661
+ return # the same pulse sample (statusd republished), or an older one
1662
+ if samples and total < samples[-1][1]:
1663
+ samples = () # the counter went down: a restarted daemon counts again
1664
+ samples += ((at, total),)
1665
+ start = at - SURGE_S
1666
+ keep = max((i for i, (when, _total) in enumerate(samples) if when <= start), default=0)
1667
+ self.surge_samples = samples[keep:][-SURGE_SAMPLES:]
1668
+
1669
+ def surge(self):
1670
+ """`429 +N`'s N: the summed upstream_429_total's growth from the newest sample at or before SURGE_S seconds
1671
+ before the latest one (the oldest this sidebar has, while it has seen less than that) to the latest."""
1672
+ samples = self.surge_samples
1673
+ return samples[-1][1] - samples[0][1] if len(samples) > 1 else 0
1674
+
1675
+ def fleet_aged(self, now):
1676
+ """(fleet stale, throttle stale): the publisher's flag, or the object's own `at` past FLEET_STALE_S /
1677
+ PULSE_STALE_S by our clock (aged)."""
1678
+ return aged(self.fleet_facts, now, FLEET_STALE_S), aged(self.throttle_facts, now, PULSE_STALE_S)
1679
+
1680
+ def poll_fleet(self):
1681
+ """Repaint when either object crosses into (or out of) stale. Polled every tick like poll_economy(): a statusd
1682
+ that stopped never changes status.json's mtime again, reload()'s only trigger. In-memory only: no I/O."""
1683
+ frozen = self.fleet_aged(time.time())
1684
+ if frozen != self.fleet_frozen:
1685
+ self.fleet_frozen, self.dirty = frozen, True
1686
+
1687
+ def fleet_money(self):
1688
+ """(text, role) of ONE pool's overflow money — `$8.5k/16k 53%` — or None (no token: every overflow figure is
1689
+ null). The pools' daily budgets are separate, non-fungible money, so the figure is never a sum: it is the pool
1690
+ nearest its cap — one at or over its alert level first, then the highest fraction spent, Anthropic on a tie (a
1691
+ pool with spend but no budget ranks last and shows `$120` alone). The attention tone when that pool's fraction
1692
+ reached its alert level (the ranking puts any such pool first)."""
1693
+ overflow = self.throttle_facts.get('overflow')
1694
+ overflow = overflow if isinstance(overflow, dict) else {}
1695
+ known = [] # (alarm, fraction or -1, spend, budget) in MONEY_POOLS order
1696
+ for key in MONEY_POOLS:
1697
+ pool = overflow.get(key) if isinstance(overflow.get(key), dict) else {}
1698
+ spent, cap = finite(pool.get('spend_today_usd')), finite(pool.get('budget_usd'))
1699
+ if spent is None:
1700
+ continue
1701
+ fraction, alert = finite(pool.get('fraction')), finite(pool.get('alert_level_pct'))
1702
+ if fraction is None and cap:
1703
+ fraction = spent / cap
1704
+ alarm = fraction is not None and alert is not None and fraction * 100 >= alert
1705
+ known.append((alarm, fraction if fraction is not None and cap else -1.0, spent, cap))
1706
+ if not known:
1707
+ return None
1708
+ alarm, fraction, spend, budget = max(known, key=lambda pool: pool[:2]) # max keeps the first on a tie
1709
+ text = '$' + compact_usd(spend)
1710
+ if budget and fraction >= 0:
1711
+ text += f'/{compact_usd(budget)} {percent(fraction)}%'
1712
+ return text, 'tone-attention' if alarm else 'ink-dim'
1713
+
1714
+ def throttle_shows_money(self):
1715
+ """True when the block's Throttle row carries the overflow money (throttle_spec's tokened head): a pulse that
1716
+ is not `up: false` and whose token gave fleet_money() a figure. An untokened or down pulse shows `up …` /
1717
+ `down`, never the money — the strip row's economy line must not yield to it (spec 2026-10-11 §3)."""
1718
+ return bool(self.throttle_facts) and self.throttle_facts.get('up') is not False \
1719
+ and self.fleet_money() is not None
1720
+
1721
+ def throttle_spec(self, now):
1722
+ """The Throttle row: (head after the lead mark, parts, worst role). Tokened: `$8.5k/16k 53% · 429 +4 · spill
1723
+ 76%`; else `up · spill 76% · 429 +4`; `down · <error>` in the urgent tone when the daemon did not answer; `no
1724
+ pulse` before statusd has published one. Spill is the most-spilling pool's, shown above 0 %."""
1725
+ facts = self.throttle_facts
1726
+ if not facts:
1727
+ return 'throttle', [[('no pulse', 'ink-dim')]], 'ink-dim'
1728
+ if facts.get('up') is False:
1729
+ parts = [[('down', 'tone-urgent')]]
1730
+ error = short_name(facts.get('error'), 24)
1731
+ if error:
1732
+ parts.append([(error, 'ink-dim')])
1733
+ return 'throttle', parts, 'tone-urgent'
1734
+ pools = facts.get('pools') if isinstance(facts.get('pools'), dict) else {}
1735
+ spills = [finite(pool.get('spill_fraction')) for pool in pools.values() if isinstance(pool, dict)]
1736
+ spill = max((value for value in spills if value is not None), default=None)
1737
+ spill = [[(f'spill {percent(spill)}%', 'ink-dim')]] if spill is not None and percent(spill) > 0 else []
1738
+ surge = self.surge()
1739
+ surge = [[(f'429 +{int(surge)}', 'ink-dim')]] if surge > 0 else []
1740
+ money = self.fleet_money()
1741
+ if money:
1742
+ return 'throttle', [[money]] + surge + spill, money[1]
1743
+ return 'throttle', [[('up' if facts.get('up') is True else '?', 'ink-dim')]] + spill + surge, 'ink-dim'
1744
+
1745
+ def provider_spec(self, key, word, now):
1746
+ """A provider's row: (head, parts, worst). The availability bar — usable/configured over BAR_CELLS cells, usable
1747
+ ▰ cooling ▱ benched ▪ dead · each in its tone (bar_segments, bar_cells) — and, after one blank, `12/19` (the
1748
+ attention tone while none of a configured pool is usable; a pool with nothing configured is no alarm), then the
1749
+ tokened pulse's worst `wk N%` / `5h N%` utilization and the soonest reset. `no data` without the pool."""
1750
+ pools = self.throttle_facts.get('pools')
1751
+ pool = pools.get(key) if isinstance(pools, dict) else None
1752
+ segments = bar_segments(pool)
1753
+ if segments is None:
1754
+ return word, [[('no data', 'ink-dim')]], 'ink-dim'
1755
+ marks = self.glyphs['marks']
1756
+ bar = [(marks[name] * cells, MARKS[name][2]) for name, cells in zip(BAR_MARKS, bar_cells(segments)) if cells]
1757
+ usable, configured = segments[0], sum(segments)
1758
+ worst = 'tone-attention' if configured and not usable else 'ink-dim'
1759
+ parts = [bar + [(' ', None), (f'{usable}/{configured}', worst)]] # `▰▰▰▱▱ 12/19`: one part, one blank
1760
+ for field, prefix in (('worst_7d', 'wk'), ('worst_5h', '5h')):
1761
+ value = finite(pool.get(field))
1762
+ if value is not None:
1763
+ parts.append([(f'{prefix} {percent(value)}%', 'ink-dim')])
1764
+ reset = finite(pool.get('soonest_reset'))
1765
+ if reset is not None and reset > now:
1766
+ parts.append([(f'reset {age3(reset - now).strip()}', 'ink-dim')])
1767
+ return word, parts, worst
1768
+
1769
+ def worse_provider(self):
1770
+ """(pool key, word) of the provider the three-row block shows, over every PROVIDERS pool: the one with no usable
1771
+ account, else the lower usable share; Anthropic, then OpenAI, on a tie. A pool with nothing configured ranks
1772
+ after every configured pool — it is no alarm (provider_spec), and a daemon without Kimi accounts reports its
1773
+ kimi pool so (DEF-2002) — and a pool the pulse does not report ranks last."""
1774
+ pools = self.throttle_facts.get('pools')
1775
+ pools = pools if isinstance(pools, dict) else {}
1776
+
1777
+ def share(provider):
1778
+ segments = bar_segments(pools.get(provider[0]))
1779
+ if segments is None:
1780
+ return 3.0
1781
+ configured = sum(segments)
1782
+ return segments[0] / configured if configured else 2.0
1783
+ return min(PROVIDERS, key=share)
1784
+
1785
+ def host_spec(self):
1786
+ """The host row: (head, parts, worst) — `productbox cpu 42% · mem 61% · load 37 · psi 3%`; cpu at
1787
+ CPU_ATTENTION (one sample), memory at MEM_ATTENTION and any `memory.full` stall in the attention tone. psi is
1788
+ the highest avg10 statusd publishes."""
1789
+ facts = self.fleet_facts
1790
+ name = short_name(facts.get('host.name'), 12) or 'host'
1791
+ if not facts:
1792
+ return name, [[('no data', 'ink-dim')]], 'ink-dim'
1793
+ parts, worst = [], 'ink-dim'
1794
+ pressure = facts.get('system.pressure') if isinstance(facts.get('system.pressure'), dict) else {}
1795
+ stalls = [value for value in map(finite, pressure.values()) if value is not None]
1796
+ full = finite(pressure.get('memory.full.avg10'))
1797
+ load = finite(facts.get('system.cpu.load_average.1m'))
1798
+ for text, value, alarm in (
1799
+ ('cpu', finite(facts.get('system.cpu.utilization')), lambda v: v >= CPU_ATTENTION),
1800
+ ('mem', finite(facts.get('system.memory.utilization')), lambda v: v >= MEM_ATTENTION)):
1801
+ if value is not None:
1802
+ role = 'tone-attention' if alarm(value) else 'ink-dim'
1803
+ parts.append([(f'{text} {percent(value)}%', role)])
1804
+ worst = max(worst, role, key=lambda r: SEVERITY.get(r, 0))
1805
+ if load is not None:
1806
+ parts.append([(f'load {whole(load)}', 'ink-dim')])
1807
+ if stalls:
1808
+ role = 'tone-attention' if full is not None and full > 0 else 'ink-dim'
1809
+ parts.append([(f'psi {whole(max(stalls))}%', role)])
1810
+ worst = max(worst, role, key=lambda r: SEVERITY.get(r, 0))
1811
+ return name, parts, worst
1812
+
1813
+ def containers_spec(self):
1814
+ """The containers row: (head, parts, worst) — `bw-tools 14G · worlds 8.4G · sakio 3.7G`, statusd's top three
1815
+ by memory in its order."""
1816
+ found = self.fleet_facts.get('containers')
1817
+ parts = []
1818
+ for container in found[:3] if isinstance(found, list) else ():
1819
+ if not isinstance(container, dict):
1820
+ continue
1821
+ name = short_name(container.get('container.name'), 14)
1822
+ used = finite(container.get('container.memory.usage.total'))
1823
+ if name and used is not None:
1824
+ parts.append([(f'{name} {compact_gib(used)}', 'ink-dim')])
1825
+ return '', parts or [[('no containers', 'ink-dim')]], 'ink-dim'
1826
+
1827
+ def paint(self, segments):
1828
+ """[(text, role or None)] painted: one SGR and one reset per run of equal roles (a 16-colour ink-dim may be SGR 2,
1829
+ which only a reset ends), a None role written bare."""
1830
+ tone, out, run, role_of_run = self.tone, [], [], None
1831
+ for text, role in segments + [('', '\0')]:
1832
+ if role != role_of_run and run:
1833
+ joined = ''.join(run)
1834
+ out.append(joined if role_of_run is None else f'{tone[role_of_run]}{joined}{tone["reset"]}')
1835
+ run = []
1836
+ role_of_run = role
1837
+ run.append(text)
1838
+ return ''.join(out)
1839
+
1840
+ def fleet_row(self, mark, spec, width, stale, link=''):
1841
+ """One block row from a spec (head, parts, worst): ` <mark> <head> ` then the parts joined by the `sep` mark,
1842
+ whole parts dropped from the end to fit `width` cells (economy_strip's rule). The lead mark takes the row's worst
1843
+ tone — of every part, a dropped one included, so a 36-column row still says something needs a look. A Throttle
1844
+ or provider head is a LABEL_CELLS field; the host's name is its own width. Stale: everything ink-dim. `link`:
1845
+ the whole row but its first blank one OSC 8 hyperlink (bytes, no cell)."""
1846
+ return self.fleet_layout(mark, spec, width, stale, link)[0]
1847
+
1848
+ def fleet_layout(self, mark, spec, width, stale, link=''):
1849
+ """fleet_row's (painted row, whole): `whole` is True when every part of the spec fit — nothing was dropped
1850
+ from the end. render() keeps the strip row's economy line unless the Throttle row is whole (spec 2026-10-11
1851
+ §3: the strip yields only when the block shows the same facts)."""
1852
+ head, parts, worst = spec
1853
+ marks = self.glyphs['marks']
1854
+ label = head.ljust(LABEL_CELLS) if mark in ('fleet-throttle', 'fleet-provider') else head
1855
+ segments = [(marks[mark], 'ink-dim' if stale else worst if worst in SEVERITY else MARKS[mark][2]),
1856
+ (f' {label} ' if label else ' ', 'ink-dim')]
1857
+ if stale and mark == 'fleet-throttle':
1858
+ segments.append((f'{marks["stale"]} ', 'ink-dim'))
1859
+ used, sep, first = 1 + sum(cell_width(text) for text, _role in segments), f' {marks["sep"]} ', True
1860
+ drawn = 0
1861
+ for part in parts:
1862
+ cost = sum(cell_width(text) for text, _role in part) + (0 if first else cell_width(sep))
1863
+ if used + cost > width:
1864
+ break
1865
+ segments += ([] if first else [(sep, 'ink-dim')]) + part
1866
+ used, first, drawn = used + cost, False, drawn + 1
1867
+ if stale:
1868
+ segments = [(text, 'ink-dim') for text, _role in segments]
1869
+ return ' ' + osc8(link, self.paint(segments)), drawn == len(parts)
1870
+
1871
+ def fleet_rule(self, cols):
1872
+ """` ── fleet ───…` to cols − 1: the rule ink-faint, the word ink-dim (divider_line's look)."""
1873
+ rule = self.glyphs['marks']['rule']
1874
+ return ' ' + self.paint([(rule * 2 + ' ', 'ink-faint'), ('fleet', 'ink-dim'),
1875
+ (' ' + rule * max(0, cols - 1 - 10), 'ink-faint')])
1876
+
1877
+ def fleet_folded(self, specs, width, stale):
1878
+ """`i`'s one line: ` fleet ✓ · throttle 53% · cpu 42% · mem 61%` — the verdict is the worst tone of every row
1879
+ (✗ urgent, ⚠ attention, ✓ done), the stale mark while either object is stale; parts dropped from the end."""
1880
+ marks = self.glyphs['marks']
1881
+ worst = max((spec[2] for spec in specs), key=lambda role: SEVERITY.get(role, 0))
1882
+ verdicts = {'tone-urgent': ('fail', 'tone-urgent'), 'tone-attention': ('warning', 'tone-attention')}
1883
+ verdict = ('stale', 'ink-dim') if stale else verdicts.get(worst, ('ok', 'tone-done'))
1884
+ parts = []
1885
+ if self.throttle_facts: # the Throttle row's first part: the money's percent, up, down, ?
1886
+ text, role = specs[0][1][0][0]
1887
+ if text.startswith('$') and text.endswith('%'):
1888
+ text = text.rsplit(' ', 1)[1]
1889
+ parts.append([(f'throttle {text}', role)])
1890
+ parts += [part for part in specs[-1][1] if part[0][0][:4] in ('cpu ', 'mem ')] # the host's cpu and mem
1891
+ segments = [(' fleet ', 'ink-dim'), (marks[verdict[0]], verdict[1])]
1892
+ used, sep = cell_width(' fleet ' + marks[verdict[0]]), f' {marks["sep"]} '
1893
+ for part in parts:
1894
+ cost = cell_width(sep) + sum(cell_width(text) for text, _role in part)
1895
+ if used + cost > width:
1896
+ break
1897
+ segments += [(sep, 'ink-dim')] + part
1898
+ used += cost
1899
+ if stale:
1900
+ segments = [(text, 'ink-dim') for text, _role in segments]
1901
+ return self.paint(segments)
1902
+
1903
+ def fleet_block(self, cols, rows, now):
1904
+ """The block's painted rows (render, render_mobile), [] when there is none: neither object published (a statusd
1905
+ before W11), a pane under FLEET_SHORT rows, or the key sheet up (it takes the rows back until the next key, so
1906
+ the whole sheet still fits a 24-row pane). Folded (`i`): one line. FLEET_FULL rows or more: the rule, Throttle,
1907
+ Anthropic, OpenAI, Moonshot (Kimi), the host, its containers; fewer: Throttle, the worse provider, the host.
1908
+ Each row at most cols − 1 cells (column 36 never written)."""
1909
+ self.block_says_money = False
1910
+ if self.showing_keys or rows < FLEET_SHORT or not (self.fleet_facts or self.throttle_facts):
1911
+ return []
1912
+ stale, width = any(self.fleet_aged(now)), max(1, cols - 1)
1913
+ throttle, host = self.throttle_spec(now), self.host_spec()
1914
+ link = self.fleet_url
1915
+ if self.fleet_collapsed:
1916
+ return [self.fleet_folded([throttle] + [self.provider_spec(k, w, now) for k, w in PROVIDERS] + [host],
1917
+ width, stale)]
1918
+ # the strip row's economy line yields only to a Throttle row that drew the money AND every part after it (429,
1919
+ # spill): a 36-column row that dropped `spill 76%` does not show the strip's facts, so the strip keeps them
1920
+ throttle_row, whole = self.fleet_layout('fleet-throttle', throttle, width, stale)
1921
+ self.block_says_money = whole and self.throttle_shows_money()
1922
+ if rows < FLEET_FULL:
1923
+ key, word = self.worse_provider()
1924
+ return [throttle_row,
1925
+ self.fleet_row('fleet-provider', self.provider_spec(key, word, now), width, stale),
1926
+ self.fleet_row('fleet-host', host, width, stale, link)]
1927
+ return [self.fleet_rule(cols), throttle_row] + [
1928
+ self.fleet_row('fleet-provider', self.provider_spec(key, word, now), width, stale) for key, word in PROVIDERS
1929
+ ] + [self.fleet_row('fleet-host', host, width, stale, link),
1930
+ self.fleet_row('fleet-host', self.containers_spec(), width, stale)]
1931
+
1932
+ def toggle_fleet(self):
1933
+ """`i`: fold the fleet block to its one line, or unfold it. On a device with an owner the fold is theirs
1934
+ (users.set_look_flag: `look.fleet_collapsed`, through the profile's lock for at most PROFILE_LOCK_S) and every
1935
+ device of theirs follows on its next tick; a write the lock refused changes nothing and says so, keeping the
1936
+ keyboard. On a device nobody owns it is this sidebar's own. Then the keyboard goes back to the lane, as `o`."""
1937
+ self.begin_action()
1938
+ self.dirty = True
1939
+ folded, g = not self.fleet_collapsed, self.glyphs['marks']
1940
+ if self.profile_path and not users.set_look_flag(self.login, self.device, 'fleet_collapsed', folded,
1941
+ directory=os.path.dirname(self.profile_path),
1942
+ timeout=PROFILE_LOCK_S):
1943
+ self.message = f'profile busy or unreadable {g["sep"]} try again'
1944
+ return
1945
+ self.fleet_collapsed = folded
1946
+ if not (self.fleet_facts or self.throttle_facts):
1947
+ self.message = f'fleet {"folded" if folded else "unfolded"} {g["sep"]} no fleet facts yet'
1948
+ else:
1949
+ self.message = f'fleet {"folded" if folded else "unfolded"} {g["sep"]} i {"unfolds" if folded else "folds"}'
1950
+ self.release_focus()
1951
+
1421
1952
  def attention(self, entry):
1422
1953
  """The attention section key of a registry entry, or None (the remainder)."""
1423
1954
  return self.agent_rows([entry])[0][3]
@@ -1447,6 +1978,22 @@ class Sidebar:
1447
1978
  lane_id = entry.get('id')
1448
1979
  return bool(lane_id) and not entry.get('divider') and any(e.get('id') == lane_id for e in self.entries)
1449
1980
 
1981
+ def workspace_docs(self):
1982
+ """This workspace's documents (plan W10 W7): the `docs.n` of every lane it lists — registry lanes (hidden ones
1983
+ too: their documents exist), zmx lanes and the sessions under Unregistered — each id once. reload() keeps it
1984
+ (`docs_total`) for the upload strip; counts are every login's (DM-LANES-DOCS-COUNTS), the page shows a login
1985
+ only what it may see."""
1986
+ ids = {entry.get('id') for rows in (self.entries, self.zmx, self.unregistered) for entry in rows or ()
1987
+ if isinstance(entry, dict)}
1988
+ return sum(docs_count(self.status.get(lane_id)) for lane_id in ids if lane_id)
1989
+
1990
+ def lane_docs_link(self, lane_id):
1991
+ """The OSC 8 target of a lane's name on the cursor row: its documents on the docs page, '' when the lane has
1992
+ none or no origin is known (plan W10 W7)."""
1993
+ if not self.web_origin or not docs_count(self.status.get(lane_id)):
1994
+ return ''
1995
+ return docs_url(self.web_origin, self.workspace, lane_id)
1996
+
1450
1997
  def header_tally(self):
1451
1998
  """[(agent status, 'glyph+count')] for the header, in `HEADER_COUNTS` order, zeros omitted; a seen `done`
1452
1999
  is not counted (it left the Done section)."""
@@ -1516,8 +2063,11 @@ class Sidebar:
1516
2063
  viewers = viewer_mark(observed).replace('·', self.glyphs['marks']['viewer'])
1517
2064
  closable = bool(entry.get('closable')) and not pending # a row being registered offers neither control
1518
2065
  can_register = closable and R.eligible(entry)
2066
+ # Plan W10 W7: the cursor row's name is one OSC 8 link to its lane's documents while it has any.
2067
+ link = self.lane_docs_link(entry['id']) if is_cursor else ''
1519
2068
  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)
2069
+ viewers, status, age, tail, tail_role, is_cursor, is_viewing, mark, mark_role, cols, section_tone, hidden,
2070
+ link)
1521
2071
  cached = self.row_cache.get(entry['id'])
1522
2072
  if cached and cached[0] == key:
1523
2073
  self.row_hits += 1
@@ -1552,7 +2102,7 @@ class Sidebar:
1552
2102
  fg = (tone['ink-faint'] if hidden else f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none'
1553
2103
  else tone['ink'] if self.vivid else '')
1554
2104
  lead = (tone['bold'] if is_viewing else '') + fg
1555
- name_cell = f'{lead}{clipped}{reset}' if lead else clipped
2105
+ name_cell = osc8(link, f'{lead}{clipped}{reset}' if lead else clipped)
1556
2106
  if viewers:
1557
2107
  name_cell += f'{tone["ink-dim"]}{viewers}{reset}'
1558
2108
  # The `+` register control (README "Daily use") has no column in spec §4.2: it takes the spacer cell 28
@@ -1799,7 +2349,7 @@ class Sidebar:
1799
2349
  def begin_action(self):
1800
2350
  """An action begins (cockpit v3 S6): the last action's message goes, so the footer never shows what an EARLIER
1801
2351
  action said beside what this one does. Called first by every verb — open (⏎ Space → J K 2 3, Tab, a click on a
1802
- row), `x`/`×`, `R`, `P`, `a`/`+`, `/`, `U` (and a click on the strip), `+`, `r`, `?`, `u`, `z`, a sidebar-key
2352
+ row), `x`/`×`, `R`, `P`, `a`/`+`, `/`, `U` (and a click on the strip), `+`, `r`, `?`, `u`, `z`, `i`, a sidebar-key
1803
2353
  chord. A cursor move, a typed letter, Esc, the wheel, a report begin nothing: a message outlives them for
1804
2354
  MESSAGE_S. An undo offer (`u`, cockpit v3 S7) is not a message: only `z`, the next `u` or UNDO_S end it."""
1805
2355
  self.message = ''
@@ -2493,6 +3043,27 @@ class Sidebar:
2493
3043
  self.message = f'Shown {name}' if hidden else f'Hid {name} {g["sep"]} o shows it'
2494
3044
  self.release_focus()
2495
3045
 
3046
+ def show_docs_link(self, entry):
3047
+ """`d` (plan W10 W7, LNS-108): the cursor lane's documents link in the footer — a message like every other
3048
+ (MESSAGE_S, or until the next action): the plain URL `https://<host>:8443/lanes-docs/?ws=<ws>&lane=<lane>`, as
3049
+ much of it as the row holds, the whole footer text one OSC 8 link to all of it (⌘/Ctrl-click, or the
3050
+ terminal's Copy Link, where 36 cells clip the text). Without an origin the path alone, no link. Then the
3051
+ keyboard goes back to the lane, as after `p`; a row with no documents, or no lane, says so and keeps it."""
3052
+ self.begin_action()
3053
+ self.dirty = True
3054
+ g = self.glyphs['marks']
3055
+ lane_id = (entry or {}).get('id')
3056
+ if not lane_id or entry.get('divider') or entry.get('action'):
3057
+ self.message = 'no docs here'
3058
+ return
3059
+ if not docs_count(self.status.get(lane_id)):
3060
+ self.message = f'no docs in {g["quote-open"]}{entry.get("name", lane_id)}{g["quote-close"]}'
3061
+ return
3062
+ url = docs_url(self.web_origin, self.workspace, lane_id)
3063
+ self.message = url
3064
+ self._message_link = url if self.web_origin else ''
3065
+ self.release_focus()
3066
+
2496
3067
  def toggle_hidden(self):
2497
3068
  """`o`: show the lanes the owner hid (faint, in their places; `h` there shows one for good) or hide them again.
2498
3069
  The footer says how many; then the keyboard goes back to the lane."""
@@ -2819,14 +3390,15 @@ class Sidebar:
2819
3390
 
2820
3391
  @message.setter
2821
3392
  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()
3393
+ # Every assignment is stamped (cockpit v3 S6): the message lasts MESSAGE_S from when it was SAID. A new message
3394
+ # is no link until `d` says it is one (plan W10 W7: show_docs_link sets `_message_link` after it).
3395
+ self._message, self.message_at, self._message_link = text, time.monotonic(), ''
2824
3396
 
2825
3397
  def expire_message(self, now=None):
2826
3398
  """run()'s tick (cockpit v3 S6): a message MESSAGE_S old goes -> True when it went (one repaint). Otherwise only
2827
3399
  the next action's begin_action() clears it — never a key, a report, the wheel or a release."""
2828
3400
  if self._message and (time.monotonic() if now is None else now) - self.message_at >= MESSAGE_S:
2829
- self._message = ''
3401
+ self._message, self._message_link = '', ''
2830
3402
  return True
2831
3403
  return False
2832
3404
 
@@ -2843,9 +3415,11 @@ class Sidebar:
2843
3415
 
2844
3416
  def strip_line(self, cols):
2845
3417
  """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."""
3418
+ or the result for 8 s. Every glyph and separator is a mark, so the ASCII frame is seven-bit. Idle while this
3419
+ workspace has documents (plan W10 W7): ` ↑ uploads · ▤ docs N (U)`, the whole text one OSC 8 link to the docs
3420
+ page once statusd knows the origin (the page is where W5's browser uploads land too); with none, today's hint."""
2847
3421
  tone, g = self.tone, self.glyphs['marks']
2848
- sep = f' {g["sep"]} '
3422
+ sep, link = f' {g["sep"]} ', ''
2849
3423
  if self.upload:
2850
3424
  count = len(self.upload['names'])
2851
3425
  text, style = f' {g["upload"]} sending {count} file{"s" if count != 1 else ""}', tone['tone-working']
@@ -2856,10 +3430,13 @@ class Sidebar:
2856
3430
  elif self.upload_note and time.time() - self.upload_note_at < 8:
2857
3431
  text = ' ' + self.upload_note
2858
3432
  style = tone['tone-done'] if self.upload_note.startswith(g['ok']) else tone['tone-urgent']
3433
+ elif self.docs_total:
3434
+ text, style = f' {g["upload"]} uploads{sep}{g["docs"]} docs {self.docs_total} (U)', tone['ink-dim']
3435
+ link = docs_url(self.web_origin) if self.web_origin else ''
2859
3436
  else:
2860
3437
  text, style = f' {g["upload"]} uploads{sep}click, then drop (U)', tone['ink-dim']
2861
3438
  text, _used = clip(text, cols - 1)
2862
- return f'{style}{text}{tone["reset"]}'
3439
+ return f'{style}{osc8(link, text)}{tone["reset"]}'
2863
3440
 
2864
3441
  def footer_text(self, cols, entries=(), legend=False):
2865
3442
  """The footer row -> (text, style, caret): ONE ladder for the desktop and the phone (cockpit v3 S6; until S6 the
@@ -2872,9 +3449,11 @@ class Sidebar:
2872
3449
  (r)` (W9 U6: the installed release lays this view out differently; `r` rebuilds it — the desktop's rung and
2873
3450
  the phone's, in the accent) › the focus legend (desktop) › the idle hint. Only the picker's way out, the
2874
3451
  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."""
3452
+ is seven-bit. `footer_link` says which link footer_row draws this frame: the message's (`d`, plan W10 W7) while
3453
+ the message is the rung shown, else none."""
2876
3454
  tone, g = self.tone, self.glyphs['marks']
2877
3455
  sep = f' {g["sep"]} '
3456
+ self.footer_link = ''
2878
3457
  if self.prompt:
2879
3458
  return self.prompt_text(cols)
2880
3459
  if self.registration:
@@ -2892,6 +3471,7 @@ class Sidebar:
2892
3471
  text, style = f' {g["warning"]} registry: ' + self.errors[0], tone['tone-urgent']
2893
3472
  elif self.message:
2894
3473
  text, style = ' ' + self.message, tone['ink-dim']
3474
+ self.footer_link = self._message_link
2895
3475
  elif self.undo: # cockpit v3 S7: the `u` it undoes, until `z` or UNDO_S
2896
3476
  text, style = self.undo_text(cols), tone['ink-dim']
2897
3477
  elif self.update_ready: # W9 U6: the installed release lays this view out differently; `r` rebuilds it
@@ -2942,11 +3522,12 @@ class Sidebar:
2942
3522
 
2943
3523
  def footer_row(self, cols, entries=(), legend=False):
2944
3524
  """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."""
3525
+ caller ends the row (`\\033[K\\033[J`); the bytes are the ones render() wrote before cockpit v3 S6. `d`'s
3526
+ message (plan W10 W7) is one OSC 8 link to the whole URL, which a 36-cell row clips."""
2946
3527
  text, style, caret = self.footer_text(cols, entries, legend)
2947
3528
  reset = self.tone['reset']
2948
3529
  painted = f'{reset}{self.tone["tone-working"]}{self.glyphs["marks"]["caret"]}{reset}' if caret else ''
2949
- return f'{style}{text}{painted}{reset}'
3530
+ return f'{style}{osc8(self.footer_link, text)}{painted}{reset}'
2950
3531
 
2951
3532
  def divider_line(self, entry, cols):
2952
3533
  """`── ◆ Needs you (3) ───` (spec §4.4), shared by the desktop and the phone (§4.10 "section headers as
@@ -3072,14 +3653,21 @@ class Sidebar:
3072
3653
  entries = self.follow() # the cursor re-resolved onto its lane, clamped (spec §4.5)
3073
3654
  self.expire_theme() # no OSC 11 answer within the grace: dark it is (spec §2.4)
3074
3655
  now = time.time()
3075
- # Frame order (spec §4.6): header, body, upload strip (rows ≥ 14), strip row (rows ≥ 18, economy or fleet
3076
- # attention to show), rule, footer. `box_top` is the upload strip's 1-based row; a click at or below it arms.
3077
- # The strip row is the fleet economy line when economy.json knows a fleet fact (Task 12), else fleet attention.
3656
+ # Frame order (spec §4.6, W11 §4.7): header, body, the fleet block (rows ≥ 18, fleet_block), upload strip
3657
+ # (rows ≥ 14), strip row (rows ≥ 18, economy or fleet attention to show), rule, footer. `box_top` is the upload
3658
+ # strip's 1-based row; a click at or below it arms, a click on the block (`block_top`, `block_rows`) opens
3659
+ # nothing. The strip row is the fleet economy line when economy.json knows a fleet fact (Task 12) and the block
3660
+ # does not show the same facts (block_says_money: the unfolded Throttle row drew the money and every part after
3661
+ # it; an untokened `up …`, a `down`, a row that dropped `spill` or `429` to fit, or the fold's bare percent do
3662
+ # not, and the strip keeps its place), else fleet attention.
3078
3663
  self.box = rows >= 14 and not self.registration and not self.attach_only
3079
- economy_line = self.economy_strip(cols - 2, now)[0] if self.economy and rows >= 18 else ''
3664
+ block = self.fleet_block(cols, rows, now)
3665
+ economy_line = (self.economy_strip(cols - 2, now)[0] if self.economy and rows >= 18
3666
+ and not (block and self.block_says_money) else '')
3080
3667
  self.strip_row = rows >= 18 and (bool(economy_line) or bool(self.fleet))
3081
- body = max(1, rows - 3 - (BOX_ROWS if self.box else 0) - (1 if self.strip_row else 0))
3082
- self.box_top = 2 + body
3668
+ body = max(1, rows - 3 - (BOX_ROWS if self.box else 0) - (1 if self.strip_row else 0) - len(block))
3669
+ self.block_top, self.block_rows = (2 + body if block else 0), len(block)
3670
+ self.box_top = 2 + body + len(block)
3083
3671
  rows, hidden_below = self.place_body(entries, body)
3084
3672
  self.body_map = rows + ([next(i for i in range(self.top, len(entries)) if i not in rows)] if hidden_below else [])
3085
3673
  self.more_row = 1 + body if hidden_below else 0 # 1-based screen row of `⋮ N more`, 0 = none
@@ -3159,6 +3747,7 @@ class Sidebar:
3159
3747
  # legend rung itself says so, in the accent (keys_here, cockpit v3 S4): ` ▌ keys go to sidebar · Esc → lane` —
3160
3748
  # no extra row, every rung above it unchanged.
3161
3749
  self.legend_shown = self.keys_here()
3750
+ out.extend(f'{line}\033[K\r\n' for line in block) # W11: pinned above the upload strip
3162
3751
  if self.box:
3163
3752
  out.append(self.strip_line(cols) + '\033[K\r\n')
3164
3753
  if self.strip_row: # spec §4.6(c): the fleet economy line first; without one, fleet attention
@@ -3177,7 +3766,9 @@ class Sidebar:
3177
3766
  self.body_map = None # the phone's rows are `top + row` (three terminal rows each); place_body is the desktop's
3178
3767
  self.box = False # the Mac file-drop helper is not available in a phone browser
3179
3768
  self.entry_top, self.entry_height = 3, 3 if rows >= 8 else 1
3180
- body = max(1, (rows - 3) // self.entry_height)
3769
+ # W11: the fleet block above the footer, as on the desktop (fleet_block: the same rows, the phone's width)
3770
+ block = self.fleet_block(cols, rows, time.time())
3771
+ body = max(1, (rows - 3 - len(block)) // self.entry_height)
3181
3772
  self.box_top = self.entry_top + body * self.entry_height
3182
3773
  self.top = max(0, min(self.top, self.cursor, len(entries) - body)) # bounded by the list, as in render()
3183
3774
  if self.cursor >= self.top + body:
@@ -3279,8 +3870,10 @@ class Sidebar:
3279
3870
  detail += sep + 'status delayed'
3280
3871
  line(' ' + detail, tone['ink-dim'])
3281
3872
  line(' ' + task if task else '', tone['ink-dim'])
3282
- for _ in range(max(0, rows - (2 + body * self.entry_height + 1))):
3873
+ for _ in range(max(0, rows - (2 + body * self.entry_height + 1 + len(block)))):
3283
3874
  line()
3875
+ self.block_top, self.block_rows = (rows - len(block) if block else 0), len(block)
3876
+ out.extend(f'{row}\033[K\r\n' for row in block)
3284
3877
  # The footer the desktop draws (footer_text, cockpit v3 S6): every prompt, the filter, the banner, the message;
3285
3878
  # only the picker's way out (`Tap Cancel`) and the idle hint (`Swipe to scroll · Tap to open`) are the phone's.
3286
3879
  out.append(self.footer_row(cols, entries) + '\033[K\033[J')
@@ -3411,6 +4004,9 @@ class Sidebar:
3411
4004
  elif button == 0 and pressed and self.box and y >= self.box_top:
3412
4005
  self.begin_action()
3413
4006
  self.armed = True # the click also gave this pane the focus: the next drop lands here
4007
+ elif (button == 0 and pressed and self.block_top
4008
+ and self.block_top <= y < self.block_top + self.block_rows):
4009
+ self.release_focus() # W11: the fleet block opens nothing (its link is the terminal's)
3414
4010
  elif button == 0 and pressed:
3415
4011
  # A click that opens nothing — a section header, `⋮ N more`, the header row, an empty row —
3416
4012
  # ends there: the keyboard goes back to the lane (cockpit v3 S3). `×` and `+` open a prompt,
@@ -3546,6 +4142,10 @@ class Sidebar:
3546
4142
  self.request_hide_workspace()
3547
4143
  elif ch == 'o':
3548
4144
  self.toggle_hidden()
4145
+ elif ch == 'i': # W11: fold / unfold the fleet block (the owner's profile)
4146
+ self.toggle_fleet()
4147
+ elif ch == 'd': # plan W10 W7: the cursor lane's docs link in the footer
4148
+ self.show_docs_link(entries[min(self.cursor, len(entries) - 1)] if entries else None)
3549
4149
  elif ch == 'P' and entries:
3550
4150
  entry = entries[min(self.cursor, len(entries) - 1)]
3551
4151
  self.start_promote(entry)
@@ -3614,6 +4214,7 @@ class Sidebar:
3614
4214
  self.poll_health()
3615
4215
  self.poll_update()
3616
4216
  self.poll_economy()
4217
+ self.poll_fleet() # W11: the fleet block's facts turning stale (no I/O)
3617
4218
  # An upload in flight no longer forces a frame: poll_upload() dirties on progress and on the end.
3618
4219
  if self.armed or (self.upload_note and time.time() - self.upload_note_at < 9):
3619
4220
  self.dirty = True