@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
@@ -82,12 +82,19 @@
82
82
  is gone, its board is hidden). Asked for, never automatic: `lanes-launch here` runs it
83
83
  for the client's own device
84
84
  lanes onboard <login> --container <name> [--workspace <id> --repo <path> [--title T]]
85
- [--write] [--no-mcp]
85
+ [--write] [--no-mcp] [--step kimi-notify|grok-build]...
86
86
  provision a user's container and agents in one verb. Dry-run by default (every diff and
87
87
  command it would run). It first checks the container (read-only docker inspect: an
88
88
  unknown one stops the run before anything is written); --write then applies, in order:
89
89
  the board (`workspaces add`, when --workspace is given), install-container,
90
- `hooks install --write` for claude and codex, `throttle setup --install-clients`
90
+ (--step grok-build first: see below) `hooks install --write` for claude and codex,
91
+ then for kimi, grok (Grok Build) and
92
+ grok-cli where that harness's home (~/.kimi-code, ~/.grok-build, ~/.grok) is in the
93
+ container (else `skipped`); two steps run only when named: --step kimi-notify (Kimi's
94
+ tui.toml notification_condition = "always": the bell on every turn end) and --step
95
+ grok-build (Grok Build installed under GROK_HOME=~/.grok-build by the stable-channel
96
+ recipe, never ~/.grok; the channel publishes no checksum, so the receipt records the
97
+ download's size and sha256 as unverified), `throttle setup --install-clients`
91
98
  inside the container as uid 1000 when it has a CONFIGURED throttle (else the manual
92
99
  step is printed: a first setup needs a person on a terminal; the laptop-pool step too
93
100
  when site.conf names LANES_THROTTLE_REMOTE_URL), sync,
@@ -149,7 +156,14 @@
149
156
  lanes upload <ws> | --active <device> [--paste]
150
157
  read a tar stream on stdin into that board's uploads/ folder (the same folder Cursor
151
158
  users drop files into); prints the in-container paths. --paste also types the path(s)
152
- into the lane that device is looking at. `lanes workspaces` lists board ids.
159
+ into the lane that device is looking at. `lanes workspaces` lists board ids. Each file
160
+ also goes to the docs store with a sha256 receipt (stderr `# docs:` lines), for the
161
+ login that owns the device; the Docs page's upload (POST /up) lands the same way.
162
+ lanes docs push <id> the hook for pushing one docs-store document to the data service's publication surface
163
+ — NOT BUILT: it prints the document's receipt (receipt.json: id, sha256, bytes, url,
164
+ at; looked up by id in every login's store, for the operator), says `not configured`
165
+ on stderr and exits 2. It sends nothing and writes nothing. Exit 1: no document with
166
+ that id, an id that is not one, or another argument (this usage)
153
167
  lanes cursor-sync [ws…] [--write] [--diff] [--canonicalize] [--identity]
154
168
  make Cursor/VS Code terminals open exactly the board's lanes through `lane open`
155
169
  (dry-run by default: prints the git-style diff; --write applies with a backup).
@@ -170,7 +184,7 @@
170
184
  current ✓ · statusd runs 1.1.0 ✗ · enrold notify ✓` — this tree's VERSION and the npm
171
185
  stamp's commit, the tree, whether ~/.local/share/lanes/current names it (✗ with the
172
186
  tree it does name), then the tree each long-running process recorded at its last start
173
- (~/.local/state/lanes/run/<name>.tree: statusd, enrold, notify, economy, studio_host,
187
+ (~/.local/state/lanes/run/<name>.tree: statusd, enrold, docsd, notify, economy, studio_host,
174
188
  the newest sidebar), ✗ for one that is not the current tree (`runs 1.1.0 ✗`) or that
175
189
  started before its tree's code (VERSION, bin/, libexec/) last changed — a checkout
176
190
  updated in place by `git pull` keeps its path (`started before its tree changed ✗`) —
@@ -185,15 +199,34 @@
185
199
  (additive; installs no agent hook and no editor: lane-edit runs $LANES_EDITOR or the
186
200
  first of micro hx nvim vim vi there) and add the Lanes MCP server to the agents'
187
201
  configs: mcpServers.lanes in ~/.claude.json and [mcp_servers.lanes] in
188
- ~/.codex/config.toml, both starting /home/coder/.lz/bin/lane-mcp — only where missing,
202
+ ~/.codex/config.toml, and where Kimi Code, Grok Build or grok-cli is installed
203
+ ~/.kimi-code/mcp.json, ~/.grok-build/config.toml (a `# >>> lanes mcp >>>` block) or
204
+ ~/.grok/user-settings.json (mcp.servers id lanes), all starting
205
+ /home/coder/.lz/bin/lane-mcp — only where missing,
189
206
  the file kept as <file>.bak-<UTC> first, atomic (a `lanes` entry that starts something
190
207
  else is left alone, exit 1; on a uid split nothing is written there, exit 1). --no-mcp
191
208
  leaves out lane-mcp and the entries (lane-edit and its vimrc still ship) and removes
192
- nothing an earlier install wrote. In the container: lane mcp install
193
- lanes hooks install --agent claude|codex (--container <name> | --home <dir>) [--dry-run | --write]
209
+ nothing an earlier install wrote. In the container: lane mcp install. The Throttle
210
+ pulse (throttle-pulse) ships to every container; in the daemon's container only
211
+ (site.conf LANES_THROTTLE_CONTAINER, else the registry's Throttle Monitor container)
212
+ its loop is started, once: `docker exec -d -u 1000 -e HOME=/home/coder <name> tmux -L
213
+ lanes-pulse new -d -s pulse ~/.lz/bin/throttle-pulse --loop` (nothing when that
214
+ session already runs; exit 1 when docker refused the start)
215
+ lanes throttle adopt-token <container>
216
+ the operator's opt-in to the pulse's burn and money lines: copy env.ANTHROPIC_API_KEY
217
+ of the container's own ~/.claude/settings.json — the LOCAL Throttle client bearer, it
218
+ must start `throttle_` — into its ~/.lz/throttle-token (0600, by rename), read and
219
+ written through /proc/<init>/root without following a link. Prints `adopted` or the
220
+ refusal, never the value. Exit 1 when refused
221
+ lanes hooks install --agent claude|codex|kimi|grok|grok-cli (--container <name> | --home <dir>) [--dry-run | --write]
194
222
  merge the lane-signal hooks into that home's ~/.claude/settings.json or ~/.codex/hooks.json
195
- (+ a top-level Codex notify when none is set); prints the diff, writes only with --write
196
- (backup kept). Opt-in, per container; Codex still asks you to trust new hooks.
223
+ (+ a top-level Codex notify when none is set), ~/.kimi-code/config.toml (a `# >>> lanes
224
+ hooks >>>` block of [[hooks]]), Grok Build's ~/.grok-build/hooks/lanes.json and
225
+ ~/.grok-build/config.toml (blocks: [compat.claude] hooks/mcps = false, [ui.notifications]
226
+ bel always, [model."grok-4.6"/"grok-4.5"] env_http_headers from LANES_TRACE) or
227
+ grok-cli's ~/.grok/user-settings.json `hooks`; prints the diff (credential values shown
228
+ as …), writes only with --write (backup kept; nothing outside a block is rewritten).
229
+ Opt-in, per container; Codex still asks you to trust new hooks.
197
230
  --write into a --container whose home the agent could not read from this account (uid
198
231
  not 1000, or a home volume not owned by 1000) is refused with the reason
199
232
  lanes invariant save|check snapshot / verify every live pane's pid and window size
@@ -269,6 +302,11 @@
269
302
  lanes-web / lanes-mobile units (⚠ not enabled, ✗ enabled but not running), the login-token
270
303
  database the running lanes-web reads (✗ when it is not the one `lanes token` uses) and the
271
304
  Tailscale Serve https:8443 mapping (⚠ not checked on a host without a Tailscale name);
305
+ the docs service: lanes-docsd enabled, active and answering on its loopback port (⚠
306
+ not enabled, ✗ enabled but not running or not answering), the pin gate (⚠ until `lanes
307
+ enrol mode request`), the store (documents, bytes, docs.log), the Mermaid pin (⚠ not
308
+ installed: install/enable-docs.sh; ✗ not the pinned build) and the age switch (✗ set
309
+ without an age binary);
272
310
  then the release that runs: ~/.local/share/lanes/current names a tree whose VERSION
273
311
  agrees with its pkg/<version> name and npm stamp and that this lanes runs (⚠ without the
274
312
  link: the rest compares with this tree); every lanes process that runs now has a run
@@ -348,6 +386,7 @@
348
386
  """
349
387
  import contextlib
350
388
  import json
389
+ import math
351
390
  import os
352
391
  import posixpath
353
392
  import pwd
@@ -777,9 +816,18 @@ def hold_sessions(stack, timeout, doing):
777
816
  return None
778
817
 
779
818
 
819
+ # The web front's own path roots (web/Caddyfile): a view session is served at /<device>, so a device of one of these
820
+ # names would sit on a route a service owns — the enrolment service (/enrol/*), the docs service (/lanes-docs/*) and
821
+ # the shell's assets (/lanes-assets/*). Compared casefolded: Caddy's path matcher ignores case.
822
+ RESERVED_DEVICES = ('enrol', 'lanes-assets', 'lanes-docs')
823
+
824
+
780
825
  def device_name(value):
781
826
  if not L.IDENT.match(value or '') or len(value) > 32:
782
827
  raise L.LanesError('Invalid device name')
828
+ if value.casefold() in RESERVED_DEVICES:
829
+ raise L.LanesError(f'Invalid device name: {value} is a path the web front reserves '
830
+ f'({", ".join(RESERVED_DEVICES)})')
783
831
  return value
784
832
 
785
833
 
@@ -1164,7 +1212,15 @@ def hook_env(home):
1164
1212
 
1165
1213
  INSTALL_CONTAINER_USAGE = 'usage: lanes install-container <name> [--no-mcp]'
1166
1214
  # lane_core.py first: `lane` imports it, so no moment has a new `lane` beside an old (or no) lane_core.py.
1167
- CONTRACT_FILES = ('lane_core.py', 'lane-mcp', 'lane', 'lane-run', 'lane-signal', 'lane-edit')
1215
+ CONTRACT_FILES = ('lane_core.py', 'lane-mcp', 'lane', 'lane-run', 'lane-signal', 'lane-edit', 'throttle-pulse')
1216
+ # The Throttle pulse (W11): installed everywhere, its loop started only in the daemon's container. As uid 1000 with
1217
+ # the container home as HOME, whatever user the image defaults to: it writes ~/.lz/state/throttle.json, which
1218
+ # lanes-statusd reads, and reads ~/.lz/throttle-token, which only the container's own user may read (0600).
1219
+ PULSE_BIN = L.CONTAINER_HOME + '/.lz/bin/throttle-pulse'
1220
+ PULSE_TMUX = ('tmux', '-L', 'lanes-pulse')
1221
+ PULSE_SESSION = 'pulse'
1222
+ PULSE_DOCKER_S = 20
1223
+ THROTTLE_MONITOR = 'tools--throttle-monitor'
1168
1224
 
1169
1225
 
1170
1226
  def lane_core():
@@ -1264,8 +1320,8 @@ def cmd_install_container(args):
1264
1320
  # user (0700), never here.
1265
1321
  install_file(L.REPO / 'container/vimrc', root / 'vimrc')
1266
1322
  # The hook DEFINITIONS travel with the contract for reference; no hook is installed here — that is the opt-in,
1267
- # per-container `lanes hooks install --agent claude|codex --container <name> --write` (RUNBOOK).
1268
- for source in sorted((L.REPO / 'container/hooks').glob('*.json')):
1323
+ # per-container `lanes hooks install --agent claude|codex|kimi|grok|grok-cli --container <name> --write` (RUNBOOK).
1324
+ for source in sorted([*(L.REPO / 'container/hooks').glob('*.json'), *(L.REPO / 'container/hooks').glob('*.toml')]):
1269
1325
  install_file(source, root / 'hooks' / source.name)
1270
1326
  env_file = L.REPO / 'container/env.sh' # TMPDIR off the RAM-backed /tmp; only NEW shells and agents read it
1271
1327
  if env_file.exists():
@@ -1280,21 +1336,142 @@ def cmd_install_container(args):
1280
1336
  if link.is_symlink() or link.exists():
1281
1337
  link.unlink()
1282
1338
  link.symlink_to(root)
1283
- print(f"installed {root}/bin/{{lane,lane_core.py,lane-run,lane-signal,lane-edit{'' if no_mcp else ',lane-mcp'},zmx-*}}, "
1339
+ print(f"installed {root}/bin/{{lane,lane_core.py,lane-run,lane-signal,lane-edit,throttle-pulse"
1340
+ f"{'' if no_mcp else ',lane-mcp'},zmx-*}}, "
1284
1341
  f'{root}/VERSION, {root}/vimrc, {root}/hooks/' + (f' and {root}/env.sh' if env_file.exists() else '')
1285
1342
  + f'; host link {link} -> {root}')
1343
+ pulse = start_pulse(container)
1286
1344
  if no_mcp:
1287
1345
  print('--no-mcp: lane-mcp and the agents\' MCP entries left out (anything an earlier install wrote is kept)')
1346
+ return pulse
1347
+ return install_mcp_entries(home) or pulse
1348
+
1349
+
1350
+ def throttle_container():
1351
+ """(container, where it was named) of the Throttle daemon's container, or (None, None): site.conf's
1352
+ LANES_THROTTLE_CONTAINER, else the container of the registry's Throttle Monitor tool (`lanes sync` names the one
1353
+ that hosts most boards' monitor)."""
1354
+ named = site_profile().get('LANES_THROTTLE_CONTAINER')
1355
+ if named and L.IDENT.match(named):
1356
+ return named, 'site.conf LANES_THROTTLE_CONTAINER'
1357
+ registry = L.read_json(L.STATE / 'registry.json', {})
1358
+ for tool in (registry.get('tools') if isinstance(registry, dict) else None) or []:
1359
+ if isinstance(tool, dict) and tool.get('id') == THROTTLE_MONITOR and L.IDENT.match(str(tool.get('container'))):
1360
+ return tool['container'], f'the registry\'s {THROTTLE_MONITOR}'
1361
+ return None, None
1362
+
1363
+
1364
+ def pulse_argv(container, *tmux):
1365
+ """`docker exec` of the pulse's tmux server in `container` as uid 1000, argv only (no shell anywhere)."""
1366
+ detach = ['-d'] if tmux[:1] == ('new',) else []
1367
+ return ['docker', 'exec', *detach, '-u', str(L.CONTAINER_UID), '-e', f'HOME={L.CONTAINER_HOME}', container,
1368
+ *PULSE_TMUX, *tmux]
1369
+
1370
+
1371
+ def start_pulse(container):
1372
+ """Start the Throttle pulse's loop in `container` when it is the daemon's container (throttle_container) and no
1373
+ `pulse` session runs on the `lanes-pulse` tmux socket there -> exit code (1 only when docker refused the start).
1374
+ Any other container gets the file only."""
1375
+ daemon, named_by = throttle_container()
1376
+ if container != daemon:
1377
+ why = f'the daemon runs in {daemon} ({named_by})' if daemon else \
1378
+ 'no daemon container is known (site.conf LANES_THROTTLE_CONTAINER, or a board with a Throttle monitor)'
1379
+ print(f'throttle pulse: installed, not started here — {why}')
1380
+ return 0
1381
+ rc, _out, _err = L.run_bounded(pulse_argv(container, 'has-session', '-t', f'={PULSE_SESSION}'), PULSE_DOCKER_S)
1382
+ if rc == 0:
1383
+ print(f'throttle pulse: already running in {container} (tmux -L lanes-pulse, session {PULSE_SESSION})')
1288
1384
  return 0
1289
- return install_mcp_entries(home)
1385
+ rc, _out, err = L.run_bounded(pulse_argv(container, 'new', '-d', '-s', PULSE_SESSION, PULSE_BIN, '--loop'),
1386
+ PULSE_DOCKER_S)
1387
+ if rc != 0:
1388
+ last = next((line.strip() for line in reversed(err.splitlines()) if line.strip()), '')
1389
+ why = f'no answer within {PULSE_DOCKER_S} s' if rc is None else f'exit {rc}'
1390
+ print(f'! throttle pulse: not started in {container} ({why}' + (f': {last[:160]}' if last else '') + ')')
1391
+ return 1
1392
+ print(f'throttle pulse: started in {container} ({named_by}): tmux -L lanes-pulse, session {PULSE_SESSION}; '
1393
+ 'it writes ~/.lz/state/throttle.json every 10 s')
1394
+ return 0
1395
+
1396
+
1397
+ THROTTLE_USAGE = 'usage: lanes throttle adopt-token <container>'
1398
+ THROTTLE_TOKEN = re.compile(r'throttle_[A-Za-z0-9_-]{8,512}')
1399
+ SETTINGS_MAX = 1 << 20
1400
+
1401
+
1402
+ def cmd_throttle(args):
1403
+ """`lanes throttle adopt-token <container>`: the operator's opt-in to the pulse's tokened reads. Copies
1404
+ env.ANTHROPIC_API_KEY of the container's own ~/.claude/settings.json — the local Throttle client bearer
1405
+ `throttle setup --install-clients` put there, never a provider credential: it must look like `throttle_…` — into
1406
+ ~/.lz/throttle-token (0600, by rename), both read and written through /proc/<container init>/root without
1407
+ following a link. Never prints the value, not even in a refusal."""
1408
+ if len(args) != 2 or args[0] != 'adopt-token' or args[1].startswith('-'):
1409
+ raise L.LanesError(THROTTLE_USAGE)
1410
+ container = args[1]
1411
+ if not L.IDENT.match(container):
1412
+ raise L.LanesError(f'Invalid container name: {container!r}')
1413
+ info = L.container_info(container)
1414
+ if not info.get('running') or not info.get('pid'):
1415
+ raise L.LanesError(f'{container} is not running: nothing adopted')
1416
+ split = uid_split(info['home']) if info.get('home') else None
1417
+ if split:
1418
+ raise L.LanesError(f'{split}: the pulse (uid {L.CONTAINER_UID}) could not read a token this account writes — '
1419
+ 'nothing adopted')
1420
+ root = f"{L.proc_root()}/{int(info['pid'])}/root"
1421
+ home = L.CONTAINER_HOME.strip('/').split('/')
1422
+ try:
1423
+ fd = L.open_real_dir(root, [*home, '.claude'])
1424
+ except OSError:
1425
+ raise L.LanesError(f'{container}: no ~/.claude directory (a real one, not a link): nothing adopted') from None
1426
+ try:
1427
+ data = L.read_regular_at(fd, 'settings.json', SETTINGS_MAX)
1428
+ finally:
1429
+ os.close(fd)
1430
+ if data is None:
1431
+ raise L.LanesError(f'{container}: ~/.claude/settings.json is not a regular file of at most 1 MiB: '
1432
+ 'nothing adopted')
1433
+ try:
1434
+ settings = json.loads(data)
1435
+ except ValueError:
1436
+ raise L.LanesError(f'{container}: ~/.claude/settings.json is not JSON: nothing adopted') from None
1437
+ env = settings.get('env') if isinstance(settings, dict) else None
1438
+ value = env.get('ANTHROPIC_API_KEY') if isinstance(env, dict) else None
1439
+ if not isinstance(value, str) or not THROTTLE_TOKEN.fullmatch(value):
1440
+ raise L.LanesError(f'{container}: env.ANTHROPIC_API_KEY in ~/.claude/settings.json is not a local Throttle '
1441
+ 'client token (throttle_…): nothing adopted — run `throttle setup --install-clients` in '
1442
+ 'the container first')
1443
+ try:
1444
+ fd = L.open_real_dir(root, [*home, '.lz'])
1445
+ except OSError:
1446
+ raise L.LanesError(f'{container}: no ~/.lz directory (a real one): run `lanes install-container '
1447
+ f'{container}` first — nothing adopted') from None
1448
+ try:
1449
+ changed = L.atomic_write_if_changed_at(fd, 'throttle-token', value + '\n', 0o600)
1450
+ finally:
1451
+ os.close(fd)
1452
+ print(f'adopted: {container} ~/.lz/throttle-token (0600)' + ('' if changed else ', unchanged')
1453
+ + '; the pulse reads it on its next cycle')
1454
+ return 0
1290
1455
 
1291
1456
 
1292
1457
  HOOK_TARGETS = {'claude': ('claude-settings.json', '.claude/settings.json'),
1293
- 'codex': ('codex-hooks.json', '.codex/hooks.json')}
1294
- HOOKS_USAGE = ('usage: lanes hooks install --agent claude|codex (--container <name> | --home <dir>) '
1458
+ 'codex': ('codex-hooks.json', '.codex/hooks.json'),
1459
+ 'kimi': ('kimi-hooks.toml', '.kimi-code/config.toml'),
1460
+ 'grok': ('grok-hooks.json', '.grok-build/hooks/lanes.json'),
1461
+ 'grok-cli': ('grok-cli-hooks.json', '.grok/user-settings.json')}
1462
+ # The other file an install changes: Codex's top-level notify; Grok Build's config.toml blocks (W12).
1463
+ HOOK_EXTRA = {'codex': ('.codex/config.toml',), 'grok': ('.grok-build/config.toml',)}
1464
+ # W12: a harness whose home is not a directory in the container is not installed there; `lanes onboard` skips it.
1465
+ HOOK_HOMES = {'kimi': '.kimi-code', 'grok': '.grok-build', 'grok-cli': '.grok'}
1466
+ HOOKS_USAGE = ('usage: lanes hooks install --agent claude|codex|kimi|grok|grok-cli (--container <name> | --home <dir>) '
1295
1467
  '[--dry-run | --write]')
1296
1468
 
1297
1469
 
1470
+ def hook_files(agent):
1471
+ """The container-home-relative files `lanes hooks install --agent <agent>` may change."""
1472
+ return (HOOK_TARGETS[agent][1],) + HOOK_EXTRA.get(agent, ())
1473
+
1474
+
1298
1475
  def render_hooks(agent):
1299
1476
  """The shipped hook definitions with `~/` spelled as the container home: the command reaches lane-signal whether
1300
1477
  or not the harness runs it through a shell that expands `~`."""
@@ -1356,16 +1533,236 @@ def codex_notify(text):
1356
1533
  + json.dumps(argv) + '\n' + text)
1357
1534
 
1358
1535
 
1536
+ SECRET_LINE = re.compile(r'^(\s*"?[A-Za-z0-9_.-]*(?:key|token|secret|password|credential)[A-Za-z0-9_.-]*"?\s*[:=]\s*)'
1537
+ r'(.+?)(,?)$', re.I)
1538
+
1539
+
1540
+ def redact_line(line):
1541
+ """A diff line whose key names a key, token, secret, password or credential, with its value shown as `…`: the
1542
+ agents' files hold credentials (grok-cli's `apiKey`, Claude's `env.ANTHROPIC_API_KEY`), and a dry run's diff is
1543
+ printed — and the onboarding printout pasted into tickets."""
1544
+ body = line.rstrip('\n')
1545
+ found = SECRET_LINE.match(body[1:]) if body[:1] in (' ', '+', '-') else None
1546
+ if not found:
1547
+ return line
1548
+ return body[0] + found.group(1) + '"…"' + found.group(3) + line[len(body):]
1549
+
1550
+
1359
1551
  def show_change(path, before, after):
1360
1552
  import difflib
1361
1553
  if before == after:
1362
1554
  print(f'{path}: no change')
1363
1555
  return False
1364
- sys.stdout.writelines(difflib.unified_diff(before.splitlines(True), after.splitlines(True),
1365
- f'{path} (now)', f'{path} (with lanes hooks)'))
1556
+ sys.stdout.writelines(redact_line(line) for line in difflib.unified_diff(
1557
+ before.splitlines(True), after.splitlines(True), f'{path} (now)', f'{path} (with lanes hooks)'))
1366
1558
  return True
1367
1559
 
1368
1560
 
1561
+ # ------------------------------------------------------------------- W12: Kimi Code, Grok Build and grok-cli hooks
1562
+ # Each file is changed through lane_core.edit_file: re-read before the write, `<file>.bak-<UTC>` first, atomic, never
1563
+ # through a symbolic link below the container home. A TOML file gets ONE marker block (lane_core.toml_block); nothing
1564
+ # outside it is rewritten. A refusal is raised to the operator as LanesError before anything is written.
1565
+
1566
+ GROK_CONFIG_BODY = ('[compat.claude]\n'
1567
+ "# Claude Code's hooks (lane-signal --harness claude) and MCP entry must not run inside Grok Build\n"
1568
+ 'hooks = false\n'
1569
+ 'mcps = false\n'
1570
+ '\n'
1571
+ '[ui.notifications]\n'
1572
+ "# a BEL on every turn end, approval and error, focused or not: the pane's window_bell_flag\n"
1573
+ 'condition = "always"\n'
1574
+ 'method = "bel"\n'
1575
+ 'events = ["turn_complete", "approval_required", "task_complete", "agent_error"]\n')
1576
+ GROK_CONFIG_VALUES = {'compat': {'claude': {'hooks': False, 'mcps': False}},
1577
+ 'ui': {'notifications': {'condition': 'always', 'method': 'bel',
1578
+ 'events': ['turn_complete', 'approval_required', 'task_complete',
1579
+ 'agent_error']}}}
1580
+ # The lanes trace on Grok Build's inference requests: each built-in model gains an env-sourced header (an override of a
1581
+ # built-in keeps its other fields; the value is read from $LANES_TRACE when the client is built, never stored).
1582
+ GROK_TRACE_MODELS = ('grok-4.6', 'grok-4.5')
1583
+ GROK_TRACE_HEADERS = {'x-throttle-trace': 'LANES_TRACE'}
1584
+
1585
+
1586
+ def _lane_error(error):
1587
+ return L.LanesError(str(error))
1588
+
1589
+
1590
+ def kimi_hooks_block():
1591
+ """The body of the shipped kimi-hooks.toml (between its marker lines), `~/` spelled as the container home."""
1592
+ rows = (L.REPO / 'container/hooks/kimi-hooks.toml').read_text().splitlines(True)
1593
+ return ''.join(rows[1:-1]).replace('"~/', f'"{L.CONTAINER_HOME}/')
1594
+
1595
+
1596
+ def _ours_hook(item):
1597
+ return isinstance(item, dict) and '/.lz/bin/lane-signal' in str(item.get('command') or '')
1598
+
1599
+
1600
+ def _toml_multiset(items):
1601
+ return sorted(json.dumps(item, sort_keys=True) for item in items)
1602
+
1603
+
1604
+ def kimi_config(text):
1605
+ """~/.kimi-code/config.toml with the lanes `[[hooks]]` block (kimi-hooks.toml) — Kimi Code reads hooks only from
1606
+ $KIMI_CODE_HOME/config.toml (`[[hooks]]` {event, matcher?, command, timeout?}, a strict schema; MEASURED in the
1607
+ 2.1.1 binary, 2026-10-15). The `.kimi-code` no-touch doctrine of the session registry concerns the device identity
1608
+ files, not this block. A `hooks` that is not an array of tables refuses; another writer's `[[hooks]]` stay."""
1609
+ core, what = lane_core(), '~/.kimi-code/config.toml'
1610
+ try:
1611
+ before = core._toml_loads(text or '', what)
1612
+ rest_text = core.without_block(text, 'hooks')
1613
+ after = core.toml_block(text, 'hooks', kimi_hooks_block(), core._lanes_command)
1614
+ except core.LaneError as error:
1615
+ raise _lane_error(f'{what}: {error}') from None
1616
+ if before is None: # no tomllib: the forms a textual rule can read
1617
+ if re.search(r'(?m)^[ \t]*hooks[ \t]*[=.]|^[ \t]*\[[ \t]*hooks[ \t]*\]', rest_text):
1618
+ raise L.LanesError(f'{what} defines `hooks` other than as [[hooks]] tables — left alone; fix it by hand')
1619
+ return after
1620
+ hooks = before.get('hooks', [])
1621
+ if not isinstance(hooks, list) or not all(isinstance(item, dict) for item in hooks):
1622
+ raise L.LanesError(f'{what}: "hooks" is not an array of tables ([[hooks]]) — left alone; fix it by hand')
1623
+ try:
1624
+ parsed = core._toml_loads(after, what)
1625
+ except core.LaneError:
1626
+ parsed = None
1627
+ expected = [item for item in hooks if not _ours_hook(item)] + core._toml_loads(kimi_hooks_block(), what)['hooks']
1628
+ if parsed is None or {k: v for k, v in parsed.items() if k != 'hooks'} != \
1629
+ {k: v for k, v in before.items() if k != 'hooks'} or \
1630
+ _toml_multiset(parsed.get('hooks', [])) != _toml_multiset(expected):
1631
+ raise L.LanesError(f'{what}: the hooks block could not be written without changing anything else — add it by '
1632
+ 'hand from ~/.lz/hooks/kimi-hooks.toml')
1633
+ return after
1634
+
1635
+
1636
+ def grok_hooks_file(_text):
1637
+ """$GROK_HOME/hooks/lanes.json: the whole file is Lanes' (grok-hooks.json, Claude Code's JSON shape, which Grok
1638
+ Build reads from every $GROK_HOME/hooks/*.json)."""
1639
+ return json.dumps(render_hooks('grok'), indent=2, ensure_ascii=False) + '\n'
1640
+
1641
+
1642
+ def _deep(doc, *keys):
1643
+ for key in keys:
1644
+ doc = doc.get(key) if isinstance(doc, dict) else None
1645
+ return doc
1646
+
1647
+
1648
+ def grok_config(text):
1649
+ """$GROK_HOME/config.toml with the lanes `hooks` block (GROK_CONFIG_BODY: `[compat.claude] hooks = false, mcps =
1650
+ false` — Grok Build otherwise also runs Claude Code's hooks and MCP servers from ~/.claude*, which say `--harness
1651
+ claude` — and `[ui.notifications]`: a BEL on every listed event, focused or not) and the `trace` block (each
1652
+ GROK_TRACE_MODELS `[model."<id>"] env_http_headers`, except a model the file configures outside the block, which
1653
+ is the person's: named instead). A `[compat.claude]` or `[ui.notifications]` of the person's refuses (TOML defines
1654
+ a table once): set those keys there by hand."""
1655
+ core, what = lane_core(), '~/.grok-build/config.toml'
1656
+ try:
1657
+ before = core._toml_loads(text or '', what)
1658
+ rest_text = core.without_block(core.without_block(text, 'hooks'), 'trace')
1659
+ rest = core._toml_loads(rest_text, what)
1660
+ except core.LaneError as error:
1661
+ raise _lane_error(f'{what}: {error}') from None
1662
+ if rest is not None:
1663
+ mine = [name for name, keys in (('[compat.claude]', ('compat', 'claude')),
1664
+ ('[ui.notifications]', ('ui', 'notifications')))
1665
+ if _deep(rest, *keys) is not None]
1666
+ free = [model for model in GROK_TRACE_MODELS if _deep(rest, 'model', model) is None]
1667
+ else: # no tomllib: what a textual rule can see
1668
+ mine = [name for name, word in (('[compat.claude]', 'compat'), ('[ui.notifications]', 'notifications'))
1669
+ if re.search(rf'(?m)^[ \t]*[^#\n]*\b{word}\b', rest_text)]
1670
+ free = [model for model in GROK_TRACE_MODELS if model not in rest_text]
1671
+ if mine:
1672
+ raise L.LanesError(f'{what} already has {" and ".join(mine)} of its own — left alone; set `[compat.claude] '
1673
+ 'hooks = false, mcps = false` and `[ui.notifications] condition = "always", method = "bel"` '
1674
+ 'there by hand')
1675
+ for model in GROK_TRACE_MODELS:
1676
+ if model not in free:
1677
+ print(f'! {what}: [model."{model}"] is configured outside the lanes block — add env_http_headers = '
1678
+ '{ "x-throttle-trace" = "LANES_TRACE" } to it by hand for the lanes trace')
1679
+ trace = ''.join(f'[model."{model}"]\nenv_http_headers = {{ "x-throttle-trace" = "LANES_TRACE" }}\n'
1680
+ for model in free)
1681
+ tables = ('[compat.claude]', '[ui.notifications]', *(f'[model."{model}"]' for model in GROK_TRACE_MODELS))
1682
+
1683
+ def ours(table):
1684
+ return table.split('\n', 1)[0].strip() in tables
1685
+ try:
1686
+ after = core.toml_block(text, 'hooks', GROK_CONFIG_BODY, ours)
1687
+ if trace:
1688
+ after = core.toml_block(after, 'trace', trace, ours)
1689
+ else:
1690
+ after = core.without_block(after, 'trace')
1691
+ except core.LaneError as error:
1692
+ raise _lane_error(f'{what}: {error}') from None
1693
+ if before is not None:
1694
+ expected = json.loads(json.dumps(rest))
1695
+ for key, value in GROK_CONFIG_VALUES.items():
1696
+ expected[key] = dict(expected.get(key) or {}, **value)
1697
+ for model in free:
1698
+ expected['model'] = dict(expected.get('model') or {}, **{model: {'env_http_headers': dict(GROK_TRACE_HEADERS)}})
1699
+ try:
1700
+ ok = core._toml_loads(after, what) == expected
1701
+ except core.LaneError:
1702
+ ok = False
1703
+ if not ok:
1704
+ raise L.LanesError(f'{what}: the lanes blocks could not be written without changing anything else — set '
1705
+ 'the keys by hand (RUNBOOK "Agent hooks")')
1706
+ return after
1707
+
1708
+
1709
+ def grok_cli_settings(text):
1710
+ """grok-cli's ~/.grok/user-settings.json with the lane-signal groups merged into its top-level `hooks` (Claude
1711
+ Code's shape; grok-cli matches a `matcher` as an exact string and runs every command through `sh -c` with its own
1712
+ environment, MEASURED in the 1.1.7 binary's source, 2026-10-15) — that one member edited, every other byte kept (the
1713
+ file holds grok-cli's apiKey); the result must parse back to the input with that member."""
1714
+ core, what = lane_core(), '~/.grok/user-settings.json'
1715
+ ours = render_hooks('grok-cli')
1716
+ if text is None or not text.strip():
1717
+ return json.dumps(ours, indent=2, ensure_ascii=False) + '\n'
1718
+ try:
1719
+ doc = core.strict_json(text, what)
1720
+ except core.LaneError as error:
1721
+ raise _lane_error(error) from None
1722
+ if not isinstance(doc, dict):
1723
+ raise L.LanesError(f'{what}: not a JSON object — refusing to rewrite it')
1724
+ merged = merge_hooks(doc, ours)['hooks']
1725
+ if merged == doc.get('hooks'):
1726
+ return text
1727
+ after = core.set_member(text, [], 'hooks', merged)
1728
+ if core.strict_json(after, what) != dict(doc, hooks=merged):
1729
+ raise L.LanesError(f'{what}: the hooks could not be merged without changing anything else — add them by hand')
1730
+ return after
1731
+
1732
+
1733
+ HOOK_EDITORS = {'kimi': (('.kimi-code/config.toml', kimi_config),),
1734
+ 'grok': (('.grok-build/hooks/lanes.json', grok_hooks_file), ('.grok-build/config.toml', grok_config)),
1735
+ 'grok-cli': (('.grok/user-settings.json', grok_cli_settings),)}
1736
+
1737
+
1738
+ def install_edits(home, edits, write):
1739
+ """Show each file's change (a diff, or `no change`) and, with `write`, apply them through lane_core.edit_file ->
1740
+ the changed paths. A refusal of any file (LanesError) is raised before anything is written."""
1741
+ core, planned = lane_core(), []
1742
+ for rel, transform in edits:
1743
+ path = home / rel
1744
+ try:
1745
+ _ident, before = core._snapshot(path, home)
1746
+ except core.LaneError as error:
1747
+ raise _lane_error(error) from None
1748
+ after = transform(before)
1749
+ if show_change(path, before or '', after):
1750
+ planned.append((path, transform))
1751
+ if not write:
1752
+ return []
1753
+ changed = []
1754
+ for path, transform in planned:
1755
+ try:
1756
+ _before, _after, saved = core.edit_file(path, transform, new_mode=0o600, backup=True, root=home)
1757
+ except core.LaneError as error:
1758
+ raise _lane_error(error) from None
1759
+ if saved:
1760
+ print(f'backup {saved}')
1761
+ print(f'wrote {path}')
1762
+ changed.append(path)
1763
+ return changed
1764
+
1765
+
1369
1766
  def write_with_backup(path, before, after):
1370
1767
  """Atomic replace, the previous bytes kept beside it first. `before` is what the diff was computed from."""
1371
1768
  if path.exists():
@@ -1381,6 +1778,9 @@ def write_with_backup(path, before, after):
1381
1778
  def cmd_hooks(args):
1382
1779
  """`lanes hooks install`: merge the lane-signal hook definitions into ONE container home's agent config. Dry run
1383
1780
  (the printed diff) unless --write; never touches Codex's hook trust (`[hooks.state]` is Codex's own decision).
1781
+ W12: kimi (a block in ~/.kimi-code/config.toml), grok (Grok Build: $GROK_HOME/hooks/lanes.json and blocks in
1782
+ $GROK_HOME/config.toml, GROK_HOME = ~/.grok-build) and grok-cli (`hooks` in ~/.grok/user-settings.json), through
1783
+ HOOK_EDITORS (`.bak-<UTC>` backups, no symbolic link followed).
1384
1784
  A --container target on a uid split (`uid_split`: this account or the home volume's owner is not the container
1385
1785
  uid) is refused with --write — the agent could not read the 0600 file this account would create — and warned in a
1386
1786
  dry run (DEF-1727). `--home <dir>` is for trying the merge on a copy, not an agent's home, and is not checked."""
@@ -1412,6 +1812,18 @@ def cmd_hooks(args):
1412
1812
  if split and opts.get('--write'):
1413
1813
  raise L.LanesError(f"{split}: the agent (uid {L.CONTAINER_UID}) could not read what this account would write "
1414
1814
  f"into {home} — nothing written; run it from a host account with uid {L.CONTAINER_UID}")
1815
+ if agent in HOOK_EDITORS:
1816
+ install_edits(home, HOOK_EDITORS[agent], bool(opts.get('--write')))
1817
+ if not opts.get('--write'):
1818
+ if split:
1819
+ print(f'⚠ {split}: --write refuses here (run it from a host account with uid {L.CONTAINER_UID})')
1820
+ print('dry run: nothing written (add --write to apply)')
1821
+ return 0
1822
+ for rel in hook_files(agent): # DEF-1998: the host never reads or writes through a link the container planted
1823
+ try:
1824
+ lane_core().no_links(home, home / rel)
1825
+ except lane_core().LaneError as error:
1826
+ raise _lane_error(f'{error}: nothing written') from None
1415
1827
  target = home / HOOK_TARGETS[agent][1]
1416
1828
  before = target.read_text() if target.exists() else ''
1417
1829
  try:
@@ -2129,8 +2541,12 @@ class UploadReader:
2129
2541
 
2130
2542
 
2131
2543
  def cmd_upload(args):
2544
+ """`lanes upload <workspace> | --active <device> [--device D] [--paste] [--request R]`: a tar stream on stdin into
2545
+ the workspace's `uploads/` AND the docs store (W10 W5, libexec/uploads.py receive_tar): each file lands as it always
2546
+ did — same folder, `-1` collision suffix, `.git/info/exclude` line — and gets a sha256 receipt, `source` ssh, for
2547
+ the login that owns the asking device (users.login_for; none: a docs.log `no-owner` line), under the lane its view
2548
+ shows (uploads.upload_lane). stdout and `<request>.done` stay the landed in-container paths, one per line."""
2132
2549
  import shlex
2133
- import tarfile
2134
2550
  registry = L.load_registry()
2135
2551
  device = args[args.index('--active') + 1] if '--active' in args else None
2136
2552
  paste_device = args[args.index('--device') + 1] if '--device' in args else device
@@ -2151,34 +2567,20 @@ def cmd_upload(args):
2151
2567
  raise L.LanesError(f'unknown board: {names[0]} (see `lanes workspaces`)')
2152
2568
  else:
2153
2569
  raise L.LanesError('usage: lanes upload <workspace> | --active <device> [--paste] (tar stream on stdin)')
2154
- folder = next((lane.get('folder') for lane in workspace['lanes'] if lane.get('folder')), None) or workspace['cwd']
2155
- info = registry['containers'].get(workspace['container']) or {}
2156
- root = L.host_path(info, folder)
2157
- uploads = root / 'uploads'
2158
- uploads.mkdir(exist_ok=True)
2159
- # Keep uploads out of commits without touching a tracked .gitignore: the repository-local exclude file.
2160
- exclude = root / '.git' / 'info' / 'exclude'
2161
- # check-ignore: 0 = ignored, 1 = not ignored; an error or no answer (None) is not a reason to append.
2162
- if (root / '.git').is_dir() and L.run_bounded(['git', '-C', str(root), 'check-ignore', '-q', 'uploads/x'], 10)[0] == 1:
2163
- exclude.parent.mkdir(exist_ok=True)
2164
- with open(exclude, 'a') as handle:
2165
- handle.write('\n# lanes upload: local intake folder, never committed\nuploads/\n')
2166
- staging = uploads / f'.incoming-{os.getpid()}'
2167
- staging.mkdir()
2168
- landed = []
2570
+ uploads_lib, users = libexec_module('uploads'), libexec_module('users')
2571
+ folder, home, parts = uploads_lib.landing(registry, workspace)
2572
+ # The view that asked (a suffixed view's sidecar, else the device named): whose upload it is, and whose lane.
2573
+ view_device = paste_target(spool, request, paste_device) if spool else paste_device
2574
+ login = users.login_for(view_device) if view_device else None
2575
+ lane = uploads_lib.upload_lane(workspace, view_device)
2576
+ uploads = uploads_lib.open_uploads(home, parts)
2169
2577
  try:
2170
2578
  stream = UploadReader(sys.stdin.buffer, spool / f'{request}.progress' if spool else None)
2171
- with tarfile.open(fileobj=stream, mode='r|') as archive:
2172
- archive.extractall(staging, filter='data') # regular files/dirs only, no absolute paths, no links out
2173
- for entry in sorted(staging.iterdir()):
2174
- final, n = uploads / entry.name, 1
2175
- while final.exists(): # never overwrite an earlier upload
2176
- final = uploads / f'{entry.stem}-{n}{entry.suffix}'
2177
- n += 1
2178
- entry.rename(final)
2179
- landed.append(f"{folder}/uploads/{final.name}")
2579
+ landed = [f'{folder}/uploads/{name}' for name in uploads_lib.receive_tar(
2580
+ stream, uploads, login=login, ws=workspace['id'], lane=lane,
2581
+ say=lambda line: print(line, file=sys.stderr, flush=True))]
2180
2582
  finally:
2181
- shutil.rmtree(staging, ignore_errors=True)
2583
+ os.close(uploads)
2182
2584
  if not landed:
2183
2585
  # The helper reports the client-side cause (eg. macOS denied Downloads). Publishing a generic .err here
2184
2586
  # races that report: the sidebar consumes it and loses the useful error before the helper can send it.
@@ -2187,7 +2589,6 @@ def cmd_upload(args):
2187
2589
  print(path)
2188
2590
  if '--paste' in args and paste_device:
2189
2591
  # The result files (.done / .err) stay in the base spool, where the asking sidebar looks for them.
2190
- view_device = paste_target(spool, request, paste_device) if spool else paste_device
2191
2592
  shown = type_into_lane(registry, device_name(view_device), workspace, ' '.join(shlex.quote(p) for p in landed) + ' ')
2192
2593
  print(f'# typed into lane: {shown}' if shown else '# could not type into the lane (not a tmux lane?)', file=sys.stderr)
2193
2594
  if spool:
@@ -2195,6 +2596,32 @@ def cmd_upload(args):
2195
2596
  return 0
2196
2597
 
2197
2598
 
2599
+ DOCS_USAGE = 'usage: lanes docs push <id>'
2600
+ # What a publication target needs and does not have yet: the data service's token-authenticated publication surface
2601
+ # (plan W10 "Operator gates" 3 — decided once that surface is stable). Until then `lanes docs push` is the hook only.
2602
+ DOCS_PUSH_UNCONFIGURED = ('lanes docs push: not configured — no publication target exists yet (the data service\'s '
2603
+ 'publication surface is not built; plan W10, operator gate 3): nothing was sent')
2604
+
2605
+
2606
+ def cmd_docs(args):
2607
+ """`lanes docs push <id>` (docs cockpit W10 W9, LNS-110): the hook for the data service's publication surface — a
2608
+ STUB, not built. It looks the document up in every login's docs store (docs_store.operator_receipt: the operator is
2609
+ the account the store belongs to, so no login's visibility applies), prints its receipt.json on stdout
2610
+ (DM-LANES-DOCS-RECEIPT, as stored: indent 2), says `not configured` on stderr and returns 2. Nothing is sent,
2611
+ written or logged. No such document, an id that is not one (docs_store.ID_RE) or any other argument: LanesError,
2612
+ exit 1."""
2613
+ if len(args) != 2 or args[0] != 'push':
2614
+ raise L.LanesError(DOCS_USAGE)
2615
+ D = libexec_module('docs_store')
2616
+ found = D.operator_receipt(args[1])
2617
+ if found is None:
2618
+ raise L.LanesError(f'docs push: no document {args[1]} in the docs store ({D.root()})')
2619
+ _meta, receipt = found
2620
+ print(json.dumps(receipt, indent=2, ensure_ascii=False))
2621
+ print(DOCS_PUSH_UNCONFIGURED, file=sys.stderr)
2622
+ return 2
2623
+
2624
+
2198
2625
  def resolve_device(name, args):
2199
2626
  """`coder-launch worlds` style: a board id given where a device is expected means "my default device"."""
2200
2627
  try:
@@ -2315,7 +2742,9 @@ def cmd_workspaces(args):
2315
2742
  # ------------------------------------------------------------------- onboard
2316
2743
 
2317
2744
  ONBOARD_USAGE = ('usage: lanes onboard <login> --container <name> [--workspace <id> --repo <path> [--title T]] '
2318
- '[--write] [--no-mcp]')
2745
+ '[--write] [--no-mcp] [--step kimi-notify|grok-build]...')
2746
+ ONBOARD_OPTIONAL = ('kimi-notify', 'grok-build') # steps that run only when named with --step (W12)
2747
+ HARNESS_NAMES = {'kimi': 'Kimi Code', 'grok': 'Grok Build', 'grok-cli': 'grok-cli'}
2319
2748
  # The receipt's status vocabulary. A dry run reports planned, unchanged, skipped, manual or failed, and `ok` for the
2320
2749
  # read-only `inspect` of the container; `manual` names a step a person still has to take (printed with the exact command);
2321
2750
  # `warn` is a step that ran and reported a problem that does not stop onboarding (the doctor's verdict, a board with
@@ -2336,7 +2765,7 @@ THROTTLE_PROBE = ('if ! command -v throttle >/dev/null; then echo absent; '
2336
2765
  THROTTLE_SETUP = 'throttle setup --install-clients'
2337
2766
  THROTTLE_PROBE_TIMEOUT = 20
2338
2767
  THROTTLE_TIMEOUT = 120
2339
- SITE_LINE = re.compile(r'^(LANES_[A-Z_]+)=([A-Za-z0-9_.:@/-]*)$') # also what `lanes client-config` hands out
2768
+ SITE_LINE = L.SITE_LINE # also what `lanes client-config` hands out
2340
2769
 
2341
2770
 
2342
2771
  def onboard_step(name, status, detail=''):
@@ -2358,13 +2787,10 @@ def uid_split(home):
2358
2787
  return None
2359
2788
 
2360
2789
 
2361
- def site_profile():
2362
- """This server's site facts: the LANES_* KEY=VALUE lines of ~/.config/lanes/site.conf (read, never executed)."""
2363
- try:
2364
- text = (Path.home() / '.config/lanes/site.conf').read_text()
2365
- except OSError:
2366
- return {}
2367
- return dict(match.groups() for match in map(SITE_LINE.match, text.splitlines()) if match)
2790
+ def site_profile(home=None):
2791
+ """This server's site facts: the LANES_* KEY=VALUE lines of ~/.config/lanes/site.conf (read, never executed;
2792
+ L.site_conf, `home` for a test's)."""
2793
+ return L.site_conf(home)
2368
2794
 
2369
2795
 
2370
2796
  def provision_throttle(container, site, write=True):
@@ -2429,13 +2855,134 @@ def provision_laptop(steps, site):
2429
2855
  return steps
2430
2856
 
2431
2857
 
2858
+ # ------------------------------------------------------------------- W12 optional onboard steps
2859
+
2860
+ KIMI_NOTIFY_LINE = 'notification_condition = "always"'
2861
+ KIMI_NOTIFY_TABLE = re.compile(r'^[ \t]*\[[ \t]*notifications[ \t]*\][ \t]*(?:#.*)?$')
2862
+ KIMI_NOTIFY_KEY = re.compile(r'^([ \t]*)notification_condition[ \t]*=[ \t]*("[^"\\]*"|\'[^\']*\')[ \t]*(#.*)?$')
2863
+
2864
+
2865
+ def kimi_notify(text):
2866
+ """~/.kimi-code/tui.toml with `[notifications] notification_condition = "always"` (operator-approved per container,
2867
+ `lanes onboard --step kimi-notify`): Kimi Code then rings the terminal bell on every turn end, focused or not, and
2868
+ tmux sets the pane's window_bell_flag. Kimi Code's own default tui.toml already HAS a `[notifications]` table
2869
+ (`enabled = true`, `notification_condition = "unfocused"`; MEASURED in the 2.1.1 binary, 2026-10-15), and TOML defines
2870
+ a table once, so that table's key is edited in place (its old value kept in a comment); only a file without the
2871
+ table gets the marker block `notify`."""
2872
+ core, what = lane_core(), '~/.kimi-code/tui.toml'
2873
+ body = '[notifications]\n' + KIMI_NOTIFY_LINE + '\n'
2874
+ if text is None or not text.strip():
2875
+ return core.toml_block(None, 'notify', body)
2876
+ try:
2877
+ before = core._toml_loads(text, what)
2878
+ rest = core.without_block(text, 'notify')
2879
+ rows = text.splitlines(True)
2880
+ _rows, first, last = core._block_rows(text, 'notify')
2881
+ except core.LaneError as error:
2882
+ raise _lane_error(f'{what}: {error}') from None
2883
+ headers = [i for i, row in enumerate(rows) if KIMI_NOTIFY_TABLE.match(row.rstrip('\r\n'))
2884
+ and (first is None or not first < i < last)]
2885
+ if first is not None and not headers:
2886
+ after = core.toml_block(text, 'notify', body)
2887
+ elif len(headers) == 1:
2888
+ start = headers[0] + 1
2889
+ end = next((i for i in range(start, len(rows)) if rows[i].lstrip().startswith('[')), len(rows))
2890
+ keys = [i for i in range(start, end) if KIMI_NOTIFY_KEY.match(rows[i].rstrip('\r\n'))]
2891
+ if len(keys) > 1 or any(re.match(r'[ \t]*notification_condition[ \t]*=', rows[i]) and i not in keys
2892
+ for i in range(start, end)):
2893
+ raise L.LanesError(f'{what}: [notifications] has a notification_condition this edit cannot read — set it '
2894
+ 'to "always" by hand')
2895
+ if keys:
2896
+ row = rows[keys[0]]
2897
+ found = KIMI_NOTIFY_KEY.match(row.rstrip('\r\n'))
2898
+ if found.group(2).strip('"\'') == 'always':
2899
+ return text
2900
+ ending = row[len(row.rstrip('\r\n')):] or '\n'
2901
+ rows[keys[0]] = (f'{found.group(1)}{KIMI_NOTIFY_LINE} # lanes onboard --step kimi-notify (was '
2902
+ f'{found.group(2)})' + ending)
2903
+ else:
2904
+ rows.insert(start, KIMI_NOTIFY_LINE + ' # lanes onboard --step kimi-notify\n')
2905
+ after = ''.join(rows)
2906
+ elif headers:
2907
+ raise L.LanesError(f'{what}: [notifications] appears {len(headers)} times — fix the file by hand')
2908
+ elif re.search(r'(?m)^[ \t]*notifications[ \t]*[=.]', rest):
2909
+ raise L.LanesError(f'{what}: notifications is not written as a [notifications] table — set '
2910
+ 'notification_condition = "always" by hand')
2911
+ else:
2912
+ after = core.toml_block(text, 'notify', body)
2913
+ if before is not None:
2914
+ expected = json.loads(json.dumps(before))
2915
+ expected['notifications'] = dict(expected.get('notifications') or {}, notification_condition='always')
2916
+ try:
2917
+ ok = core._toml_loads(after, what) == expected
2918
+ except core.LaneError:
2919
+ ok = False
2920
+ if not ok:
2921
+ raise L.LanesError(f'{what}: notification_condition could not be set without changing anything else — '
2922
+ 'set it to "always" by hand')
2923
+ return after
2924
+
2925
+
2926
+ # `lanes onboard --step grok-build`: Grok Build installed under GROK_HOME=~/.grok-build inside the container, as uid
2927
+ # 1000, by the measured recipe (evidence 2026-10-15 "Container install recipe", derived from x.ai's install.sh): the
2928
+ # stable channel's version, the zstd artifact (else the plain one), the binary at downloads/grok-linux-x86_64 and the
2929
+ # relative link bin/grok (so the pane's command and comm stay `grok`), `[cli] installer = "internal"`. It never writes
2930
+ # ~/.grok (grok-cli's: Grok Build's own installer would replace grok-cli's bin/grok there), edits no shell rc file and
2931
+ # links nothing into ~/.local/bin. The channel publishes no checksum (no GitHub release assets; MEASURED 2026-10-15), so
2932
+ # the download cannot be verified: the step prints and records its size and sha256 as UNVERIFIED, and it runs only
2933
+ # when the operator names it (`--step grok-build`).
2934
+ GROK_BUILD_TIMEOUT = 900
2935
+ GROK_BUILD_RECIPE = (
2936
+ 'set -eu; G="$HOME/.grok-build"; '
2937
+ '[ "$(uname -m)" = x86_64 ] || { echo "grok-build: only linux x86_64 builds exist, this is $(uname -m)" >&2; exit 3; }; '
2938
+ 'V=$(curl -fsSL https://x.ai/cli/stable); '
2939
+ 'case "$V" in ""|*[!0-9.]*) echo "grok-build: the stable channel answered no version" >&2; exit 3;; esac; '
2940
+ 'mkdir -p "$G/downloads" "$G/bin"; P="$G/downloads/.grok-linux-x86_64.part"; rm -f "$P" "$P.zst"; '
2941
+ 'if command -v zstd >/dev/null 2>&1 && curl -fsSL -o "$P.zst" "https://x.ai/cli/grok-$V-linux-x86_64.zst"; '
2942
+ 'then zstd -q -dc "$P.zst" > "$P"; rm -f "$P.zst"; '
2943
+ 'else rm -f "$P.zst"; curl -fsSL -o "$P" "https://x.ai/cli/grok-$V-linux-x86_64"; fi; '
2944
+ 'chmod 755 "$P"; mv -f "$P" "$G/downloads/grok-linux-x86_64"; '
2945
+ 'ln -sfn ../downloads/grok-linux-x86_64 "$G/bin/grok"; '
2946
+ '[ -e "$G/config.toml" ] || printf \'[cli]\\ninstaller = "internal"\\n\' > "$G/config.toml"; '
2947
+ 'R=$(GROK_HOME="$G" "$G/bin/grok" --version 2>/dev/null | head -n 1); '
2948
+ 'echo "grok-build version=$V bytes=$(wc -c < "$G/downloads/grok-linux-x86_64") '
2949
+ 'sha256=$(sha256sum "$G/downloads/grok-linux-x86_64" | cut -d" " -f1) runs=$R"')
2950
+ GROK_BUILD_LINE = re.compile(r'^grok-build version=([0-9.]+) bytes=(\d+) sha256=([0-9a-f]{64}) runs=(.*)$')
2951
+
2952
+
2953
+ def provision_grok_build(container, write):
2954
+ """The `grok-build` onboard step -> (status, detail). A dry run prints the command; --write runs it (bounded,
2955
+ GROK_BUILD_TIMEOUT) and reports the installed version with the download's size and sha256, marked unverified."""
2956
+ uid = str(L.CONTAINER_UID)
2957
+ argv = ['docker', 'exec', '-u', uid, container, 'sh', '-lc', GROK_BUILD_RECIPE]
2958
+ shown = f"docker exec -u {uid} {container} sh -lc '<GROK_BUILD_RECIPE>' (GROK_HOME=~/.grok-build)"
2959
+ if not write:
2960
+ print(f'would run: {shown}')
2961
+ return 'planned', 'Grok Build into ~/.grok-build (stable channel; the download is unverified: no checksum)'
2962
+ rc, out, err = L.run_bounded(argv, GROK_BUILD_TIMEOUT)
2963
+ found = next((GROK_BUILD_LINE.match(line.strip()) for line in reversed(out.splitlines())
2964
+ if GROK_BUILD_LINE.match(line.strip())), None)
2965
+ if rc != 0 or not found:
2966
+ last = next((line.strip() for line in reversed((err or '').splitlines()) if line.strip()), '')
2967
+ reason = f'no answer within {GROK_BUILD_TIMEOUT} s' if rc is None else f'exit {rc}'
2968
+ print(f'✗ grok-build: {reason}' + (f' ({last[:160]})' if last else ''))
2969
+ return 'failed', f'{reason}: Grok Build not installed in {container}' + (f' ({last[:160]})' if last else '')
2970
+ version, size, digest, runs = found.groups()
2971
+ detail = (f'Grok Build {version} in ~/.grok-build (bin/grok -> downloads/grok-linux-x86_64; {runs or "no --version"}'
2972
+ f') — download UNVERIFIED (the stable channel publishes no checksum): {size} bytes, sha256 {digest}')
2973
+ print(f'⚠ grok-build: {detail}')
2974
+ return 'warn', detail
2975
+
2976
+
2432
2977
  def onboard_args(args):
2433
2978
  if not args or args[0].startswith('-'):
2434
2979
  raise L.LanesError(ONBOARD_USAGE)
2435
2980
  opts, rest = {}, list(args[1:])
2436
2981
  while rest:
2437
2982
  word = rest.pop(0)
2438
- if word in ('--container', '--workspace', '--repo', '--title') and rest and word not in opts:
2983
+ if word == '--step' and rest and rest[0] in ONBOARD_OPTIONAL:
2984
+ opts.setdefault('--step', []).append(rest.pop(0))
2985
+ elif word in ('--container', '--workspace', '--repo', '--title') and rest and word not in opts:
2439
2986
  opts[word] = rest.pop(0)
2440
2987
  elif word in ('--write', '--no-mcp'):
2441
2988
  opts[word] = True
@@ -2577,10 +3124,20 @@ def cmd_onboard(args):
2577
3124
  if lane_core().read_regular(path, lane_core().EDIT_MAX) != mcp_before.get(path)]
2578
3125
  return ('changed', ', '.join(changed)) if changed else ('unchanged', files)
2579
3126
 
3127
+ def installed(agent):
3128
+ """None when `agent` is installed in the container (its home is a real directory), else why not."""
3129
+ rel = HOOK_HOMES.get(agent)
3130
+ if rel is None or (home() / rel).is_dir() and not (home() / rel).is_symlink():
3131
+ return None
3132
+ return f'no ~/{rel} in the container: {HARNESS_NAMES[agent]} is not installed there'
3133
+
2580
3134
  def hooks_step(agent):
2581
3135
  def step():
2582
3136
  target = home()
2583
- files = [target / HOOK_TARGETS[agent][1]] + ([target / '.codex/config.toml'] if agent == 'codex' else [])
3137
+ missing = installed(agent)
3138
+ if missing:
3139
+ return 'skipped', missing + (' (--step grok-build installs it)' if agent == 'grok' else '')
3140
+ files = [target / rel for rel in hook_files(agent)]
2584
3141
  before = [path.read_bytes() if path.exists() else None for path in files]
2585
3142
  cmd_hooks(['install', '--agent', agent, '--container', container, '--write' if write else '--dry-run'])
2586
3143
  if not write:
@@ -2590,6 +3147,24 @@ def cmd_onboard(args):
2590
3147
  return ('changed', ', '.join(changed)) if changed else ('unchanged', str(files[0]))
2591
3148
  return step
2592
3149
 
3150
+ def kimi_notify_step():
3151
+ if 'kimi-notify' not in opts.get('--step', []):
3152
+ return 'skipped', 'optional: --step kimi-notify (Kimi Code rings the bell on every turn end, focused or not)'
3153
+ missing = installed('kimi')
3154
+ if missing:
3155
+ return 'skipped', missing
3156
+ path = home() / '.kimi-code/tui.toml'
3157
+ before = path.read_bytes() if path.exists() else None
3158
+ install_edits(home(), (('.kimi-code/tui.toml', kimi_notify),), write)
3159
+ if not write:
3160
+ return 'planned', str(path)
3161
+ return ('changed' if (path.read_bytes() if path.exists() else None) != before else 'unchanged'), str(path)
3162
+
3163
+ def grok_build_step():
3164
+ if 'grok-build' not in opts.get('--step', []):
3165
+ return 'skipped', 'optional: --step grok-build (installs Grok Build into ~/.grok-build)'
3166
+ return provision_grok_build(container, write)
3167
+
2593
3168
  def sync_step():
2594
3169
  if not write:
2595
3170
  print('would run: lanes sync')
@@ -2643,9 +3218,13 @@ def cmd_onboard(args):
2643
3218
 
2644
3219
  print(f"lanes onboard {login} -> container {container}" + (f', board {board}' if board else '')
2645
3220
  + ('' if write else ' (dry run)'))
2646
- plan = [('inspect', inspect_step, True), ('board', board_step, True), ('contract', contract_step, True),
3221
+ # grok-build (opt-in) runs before the contract, so install-container's MCP entries and hooks-grok find its home in
3222
+ # the same run.
3223
+ plan = [('inspect', inspect_step, True), ('board', board_step, True), ('grok-build', grok_build_step, False),
3224
+ ('contract', contract_step, True),
2647
3225
  ('lane-mcp', mcp_step, False), ('hooks-claude', hooks_step('claude'), False),
2648
- ('hooks-codex', hooks_step('codex'), False),
3226
+ ('hooks-codex', hooks_step('codex'), False), ('hooks-kimi', hooks_step('kimi'), False),
3227
+ ('kimi-notify', kimi_notify_step, False), ('hooks-grok', hooks_step('grok'), False), ('hooks-grok-cli', hooks_step('grok-cli'), False),
2649
3228
  ('throttle', lambda: provision_throttle(container, site, write), False),
2650
3229
  ('sync', sync_step, False), ('cursor-sync', cursor_step, False), ('resync', resync_step, False),
2651
3230
  ('doctor', doctor_step, False)]
@@ -2661,7 +3240,8 @@ def cmd_onboard(args):
2661
3240
  for key, value in (('login', login), ('container', container), ('workspace', board or '-')):
2662
3241
  print(f'{key + ":":<14}{value}')
2663
3242
  for step in steps:
2664
- print(f"{step['name'] + ':':<14}{step['status']:<10} {step['detail']}")
3243
+ print(f"{step['name'] + ':':<14}" + ('' if len(step['name']) < 13 else ' ') # hooks-grok-cli: 15 wide
3244
+ + f"{step['status']:<10} {step['detail']}")
2665
3245
  if write:
2666
3246
  print(f"{'receipt:':<14}{onboard_receipt(login, container, board, steps)}")
2667
3247
  else:
@@ -2768,7 +3348,7 @@ def cmd_hello(args):
2768
3348
 
2769
3349
  # The record_tree names, in the order `lanes version` lists them: statusd first (every view's status comes from it),
2770
3350
  # then the services, then the newest sidebar. A name not listed (a newer tree's process) follows, sorted.
2771
- RUN_ORDER = ('statusd', 'enrold', 'notify', 'economy', 'studio_host', 'sidebar')
3351
+ RUN_ORDER = ('statusd', 'enrold', 'docsd', 'notify', 'economy', 'studio_host', 'sidebar')
2772
3352
  VERSION_SCHEMA = 'lanes.version/1'
2773
3353
  COMMIT_RE = re.compile(r'([0-9a-f]{7})[0-9a-f]{0,33}(-dirty)?') # scripts/build-payload.mjs: rev-parse HEAD [-dirty]
2774
3354
  # A tree's code, as far as a recorded process runs it: VERSION and the regular files directly in these folders (every
@@ -3919,6 +4499,25 @@ def cmd_device(args):
3919
4499
  return 0
3920
4500
 
3921
4501
 
4502
+ def unit_properties(units):
4503
+ """One bounded `systemctl --user show` of `units` (Id, LoadState, ActiveState, UnitFileState, MainPID) ->
4504
+ ({unit: {property: value}}, None), or (None, why) when systemctl did not answer or failed. Read-only."""
4505
+ argv = ['systemctl', '--user', 'show', '-p', 'Id', '-p', 'LoadState', '-p', 'ActiveState', '-p', 'UnitFileState',
4506
+ '-p', 'MainPID', *units]
4507
+ rc, out, err = L.run_bounded(argv, 10, env=clean_env())
4508
+ if rc != 0:
4509
+ return None, 'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'
4510
+ found = {}
4511
+ for block in out.strip().split('\n\n'):
4512
+ props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
4513
+ found[props.get('Id', '')] = props
4514
+ return found, None
4515
+
4516
+
4517
+ def unit_enabled(props):
4518
+ return props.get('UnitFileState', '').startswith(('enabled', 'linked', 'alias'))
4519
+
4520
+
3922
4521
  def web_doctor():
3923
4522
  """`lanes doctor`'s browser/phone lines -> [(mark, text)], mark ✓ | ⚠ | ✗ (LNS-50). One bounded
3924
4523
  `systemctl --user show` for both web units, a read of the running lanes-web's environment, `tailscale status --json`
@@ -3932,20 +4531,14 @@ def web_doctor():
3932
4531
  if not shutil.which('systemctl'):
3933
4532
  lines.append(('⚠', 'web units not checked — no systemctl on this host (`lanes web` needs systemd user services)'))
3934
4533
  else:
3935
- argv = ['systemctl', '--user', 'show', '-p', 'Id', '-p', 'LoadState', '-p', 'ActiveState', '-p', 'UnitFileState',
3936
- '-p', 'MainPID', *WEB_UNITS]
3937
- rc, out, err = L.run_bounded(argv, 10, env=clean_env())
3938
- if rc != 0:
3939
- lines.append(('✗', f"web units NOT checked — systemctl --user {'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'}"))
4534
+ units, why = unit_properties(WEB_UNITS)
4535
+ if units is None:
4536
+ lines.append(('✗', f'web units NOT checked — systemctl --user {why}'))
3940
4537
  else:
3941
- units = {}
3942
- for block in out.strip().split('\n\n'):
3943
- props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
3944
- units[props.get('Id', '')] = props
3945
4538
  for unit in WEB_UNITS:
3946
4539
  props = units.get(unit, {})
3947
4540
  active = props.get('ActiveState') or 'unknown'
3948
- enabled = props.get('UnitFileState', '').startswith(('enabled', 'linked', 'alias'))
4541
+ enabled = unit_enabled(props)
3949
4542
  enabled_any = enabled_any or enabled
3950
4543
  if enabled and active == 'active':
3951
4544
  lines.append(('✓', f'{unit} active'))
@@ -3990,6 +4583,414 @@ def web_doctor():
3990
4583
  return lines
3991
4584
 
3992
4585
 
4586
+ # ------------------------------------------------------------------ doctor: the docs service (W10 W8, LNS-109)
4587
+ DOCS_UNIT = 'lanes-docsd.service'
4588
+ DOCS_PROBE_S = 2 # seconds the loopback connect to lanes-docsd may take
4589
+ ENABLE_DOCS = 'install/enable-docs.sh'
4590
+
4591
+
4592
+ def home_shown(path, home=None):
4593
+ """`path` as `~/…` under the home, else as it is (plain)."""
4594
+ try:
4595
+ return plain(f'~/{Path(path).relative_to(Path(home) if home is not None else Path.home())}')
4596
+ except (ValueError, KeyError, RuntimeError):
4597
+ return plain(path)
4598
+
4599
+
4600
+ def byte_size(count):
4601
+ """A size for a person: `812 B`, `3.4 KiB`, `17.0 MiB`, `1.2 GiB`."""
4602
+ if count < 1024:
4603
+ return f'{count} B'
4604
+ for unit in ('KiB', 'MiB', 'GiB'):
4605
+ count /= 1024
4606
+ if count < 1024 or unit == 'GiB':
4607
+ return f'{count:.1f} {unit}'
4608
+
4609
+
4610
+ def tree_bytes(root):
4611
+ """(regular files, their bytes) under the directory `root`, never through a link (a link at `root` itself counts
4612
+ nothing); (0, 0) when it does not exist. Unreadable entries are skipped."""
4613
+ if os.path.islink(root):
4614
+ return 0, 0
4615
+ files = total = 0
4616
+ stack = [os.fspath(root)]
4617
+ while stack:
4618
+ try:
4619
+ with os.scandir(stack.pop()) as entries:
4620
+ for entry in entries:
4621
+ try:
4622
+ if entry.is_dir(follow_symlinks=False):
4623
+ stack.append(entry.path)
4624
+ elif entry.is_file(follow_symlinks=False):
4625
+ files, total = files + 1, total + entry.stat(follow_symlinks=False).st_size
4626
+ except OSError:
4627
+ continue
4628
+ except OSError:
4629
+ continue
4630
+ return files, total
4631
+
4632
+
4633
+ def docs_port():
4634
+ """(port, None): the port lanes-docsd listens on — LANES_DOCS_PORT from its drop-in (docs_store.unit_environment,
4635
+ the files `lanes upload` reads too), else docsd.DEFAULT_PORT — or (None, why) for a setting that is not a port."""
4636
+ value = libexec_module('docs_store').unit_environment().get('LANES_DOCS_PORT') or ''
4637
+ if not value:
4638
+ return libexec_module('docsd').DEFAULT_PORT, None
4639
+ if value.isdigit() and 0 < int(value) <= 65535:
4640
+ return int(value), None
4641
+ return None, f'LANES_DOCS_PORT={plain(value)[:20]} in its drop-in is not a port number'
4642
+
4643
+
4644
+ def port_answers(port, timeout=DOCS_PROBE_S):
4645
+ """Whether 127.0.0.1:<port> accepts a TCP connection within `timeout` s. It connects and closes without sending a
4646
+ byte, so lanes-docsd answers nothing and writes no docs.log line (its handler reads an empty request line)."""
4647
+ import socket as sockets
4648
+ try:
4649
+ with sockets.create_connection(('127.0.0.1', port), timeout=timeout):
4650
+ return True
4651
+ except OSError:
4652
+ return False
4653
+
4654
+
4655
+ def docs_unit_line(probe):
4656
+ restart = f'journalctl --user -u {DOCS_UNIT}; systemctl --user restart {DOCS_UNIT}'
4657
+ if not shutil.which('systemctl'):
4658
+ return ('⚠', f'{DOCS_UNIT} not checked — no systemctl on this host')
4659
+ units, why = unit_properties([DOCS_UNIT])
4660
+ if units is None:
4661
+ return ('✗', f'{DOCS_UNIT} NOT checked — systemctl --user {why}')
4662
+ props = units.get(DOCS_UNIT, {})
4663
+ active, enabled = props.get('ActiveState') or 'unknown', unit_enabled(props)
4664
+ if active != 'active':
4665
+ if enabled:
4666
+ return ('✗', f'{DOCS_UNIT} enabled but {plain(active)} — {restart}')
4667
+ return ('⚠', f'{DOCS_UNIT} not enabled, so there is no docs page — the full server install enables it (an '
4668
+ f'update with --update-only enables nothing): systemctl --user enable --now {DOCS_UNIT}')
4669
+ port, why = docs_port()
4670
+ if port is None:
4671
+ return ('✗', f'{DOCS_UNIT} active, but {why} — fix it with systemctl --user edit {DOCS_UNIT}')
4672
+ where = f'127.0.0.1:{port}'
4673
+ if not probe(port):
4674
+ return ('✗', f'{DOCS_UNIT} active, but {where} does not answer — {restart}')
4675
+ if not enabled:
4676
+ return ('⚠', f'{DOCS_UNIT} active on {where} but not enabled: a reboot loses it — systemctl --user enable '
4677
+ f'{DOCS_UNIT}')
4678
+ return ('✓', f'{DOCS_UNIT} active, answering on {where}')
4679
+
4680
+
4681
+ def docs_pin_line():
4682
+ """The pin gate as docsd reads it on every request (enrold.read_mode): an operator gate, so never a ✗."""
4683
+ mode, problem = libexec_module('enrold').read_mode()
4684
+ if mode == 'request':
4685
+ return ('✓', 'docs: pinned — lanes-docsd answers members through Tailscale Serve (lanes enrol mode request)')
4686
+ said = problem or ('pinned invite only' if mode == 'invite' else 'unpinned: auto')
4687
+ return ('⚠', f'docs: refuses until the Serve header check is pinned ({plain(said)}): every request is 403 '
4688
+ 'not-pinned — run the check (RUNBOOK "Enrolment service", Operator verification), then lanes enrol '
4689
+ 'mode request (RUNBOOK "Docs service")')
4690
+
4691
+
4692
+ def docs_store_line(home=None):
4693
+ D = libexec_module('docs_store')
4694
+ counts = D.read_counts()
4695
+ documents = sum(entry['n'] for entry in counts.values())
4696
+ _files, size = tree_bytes(D.root())
4697
+ try:
4698
+ log = f'docs.log {byte_size(D.log_path().lstat().st_size)}'
4699
+ except FileNotFoundError:
4700
+ log = 'no docs.log yet'
4701
+ except OSError as error:
4702
+ log = f'docs.log not readable ({error.strerror or error})'
4703
+ return ('', f"docs store: {documents} document{'' if documents == 1 else 's'} in {len(counts)} "
4704
+ f"lane{'' if len(counts) == 1 else 's'} (counts.json), {byte_size(size)} under "
4705
+ f'{home_shown(D.root(), home)}; {log}')
4706
+
4707
+
4708
+ def docs_mermaid_line(home=None):
4709
+ """holders.lock's opt-in `mermaid` pin against the file install/enable-docs.sh installs: the member digest, read
4710
+ without following a link or blocking on a FIFO."""
4711
+ import hashlib
4712
+ SH = libexec_module('studio_host')
4713
+ script = f"sh {home_shown(L.CURRENT, home)}/{ENABLE_DOCS}"
4714
+ try:
4715
+ pin = SH.parse_lock((L.REPO / 'holders.lock').read_text()).get('mermaid')
4716
+ why = 'no mermaid line' if pin is None else '' if pin.opt_in else 'the mermaid line is not an opt-in pin'
4717
+ except (OSError, ValueError, SH.Refusal) as error:
4718
+ pin, why = None, str(error)
4719
+ if why:
4720
+ return ('✗', f'docs: Mermaid not checked — {L.REPO / "holders.lock"}: {plain(why)}')
4721
+ target = L.SHARE / 'vendor' / f'mermaid-{pin.version}.min.js'
4722
+ shown, version = home_shown(target, home), plain(pin.version)
4723
+ try:
4724
+ fd = os.open(target, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0))
4725
+ except FileNotFoundError:
4726
+ return ('⚠', f'docs: Mermaid {version} not installed ({shown}), so diagrams show as source — run {script} '
4727
+ '(every server install and update runs it)')
4728
+ except OSError as error:
4729
+ return ('✗', f'docs: Mermaid {version} refused — {shown} is not a regular file '
4730
+ f'({error.strerror or error}) — {script} replaces it from a verified download')
4731
+ try:
4732
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
4733
+ return ('✗', f'docs: Mermaid {version} refused — {shown} is not a regular file — {script} replaces it '
4734
+ 'from a verified download')
4735
+ digest = hashlib.sha256()
4736
+ for chunk in iter(lambda: os.read(fd, 1 << 20), b''):
4737
+ digest.update(chunk)
4738
+ finally:
4739
+ os.close(fd)
4740
+ actual = digest.hexdigest()
4741
+ if actual != pin.binary_sha256:
4742
+ return ('✗', f'docs: Mermaid {version} refused — {shown} is not the pinned build (sha256 {actual[:12]}…, '
4743
+ f'holders.lock {pin.binary_sha256[:12]}…): browsers refuse it by its integrity, so diagrams '
4744
+ f'show as source — {script} replaces it from a verified download')
4745
+ return ('✓', f'docs: Mermaid {version} pinned ({shown}, sha256 {actual[:12]}…)')
4746
+
4747
+
4748
+ def docs_age_line():
4749
+ """The store's encryption switch (docs_store.age_settings: this environment, else lanes-docsd's drop-in, as
4750
+ `lanes upload` reads it) and the `age` on this PATH. The recipient is never printed."""
4751
+ D = libexec_module('docs_store')
4752
+ binary = D.age_binary()
4753
+ has = f'age at {plain(binary)}' if binary else 'no age binary on PATH'
4754
+ try:
4755
+ recipient, identity = D.age_settings()
4756
+ except D.DocsError as error:
4757
+ return ('✗', f'docs: age encryption NOT usable — {error}: every upload and publish fails until it is fixed '
4758
+ f'({home_shown(D.dropin_dir())})')
4759
+ if recipient is None:
4760
+ return ('', f'docs: age encryption off (LANES_DOCS_AGE_RECIPIENT unset; {has})')
4761
+ if binary is None:
4762
+ return ('✗', 'docs: age encryption on (LANES_DOCS_AGE_RECIPIENT set) but there is no age binary on PATH: every '
4763
+ 'upload and publish fails (encrypt-failed) — install age or remove the setting (the server '
4764
+ 'install refuses it)')
4765
+ opens = ('lanes-docsd opens them with LANES_DOCS_AGE_IDENTITY' if identity else
4766
+ 'no LANES_DOCS_AGE_IDENTITY, so their downloads answer 409 encrypted')
4767
+ return ('✓', f'docs: age encryption on — new store copies are age output ({has}); {opens}')
4768
+
4769
+
4770
+ def docs_doctor(home=None, probe=None):
4771
+ """`lanes doctor`'s docs lines (W10 W8, LNS-109) -> [(mark, text)], mark ✓ | ✗ | ⚠ | '' (information), in this
4772
+ order; read-only (one bounded `systemctl --user show`, one loopback connect, file reads):
4773
+ lanes-docsd.service — enabled, active and its loopback port (docs_port) accepting a connection ✓ · enabled but not
4774
+ active, or active while its port refuses ✗ · active but not enabled ⚠ (a reboot loses it) · not enabled ⚠ (the
4775
+ full server install enables it; an update with --update-only enables nothing) · systemctl silent ✗ · no
4776
+ systemctl ⚠. `probe` (port -> bool) stands in for port_answers.
4777
+ the pin gate — `request` ✓ `docs: pinned` · else ⚠ `docs: refuses until the Serve header check is pinned`
4778
+ the store — information: documents and lanes from counts.json, the bytes under docs/, docs.log's size
4779
+ the Mermaid pin — the pinned member digest ✓ · absent ⚠ (run install/enable-docs.sh) · another digest or not a
4780
+ regular file ✗ (refused: browsers refuse it by its integrity) · no valid pin ✗
4781
+ the `age` switch — unset: information · set with an `age` on PATH ✓ · set without one, or no recipient ✗"""
4782
+ home = Path(home) if home is not None else Path.home()
4783
+ return [docs_unit_line(port_answers if probe is None else probe), docs_pin_line(), docs_store_line(home),
4784
+ docs_mermaid_line(home), docs_age_line()]
4785
+
4786
+
4787
+ # ------------------------------------------------------------------ doctor: the fleet and the Throttle pulse (W11)
4788
+ # Four lines (W12 added the pools line) from what statusd published (spec
4789
+ # docs/superpowers/specs/2026-10-11-lanes-fleet-dashboard-design.md §2, §4): status.json's `throttle` (the
4790
+ # container-side pulse's ~/.lz/state/throttle.json as statusd copied it, with `stale`) and `fleet` (host and top
4791
+ # containers, OTel names as keys), and site.conf's LANES_FLEET_DASHBOARD_URL. doctor reads files only: it never asks the
4792
+ # daemon, never starts the pulse and never runs docker. A missing pulse, a daemon that does not answer and a stale fleet
4793
+ # are warnings (⚠), never a ✗: Lanes runs without Throttle and without them. The pulse rewrites its file at least every
4794
+ # 60 s, but on a 10 s loop with `at` truncated to the second a healthy one is routinely read 60–84 s after its `at`:
4795
+ # statusd calls it stale past 90 s (statusd.PULSE_STALE_S, DEF-1941), and doctor's own clock uses the same bound, so the
4796
+ # two never disagree about a healthy pulse.
4797
+ PULSE_FRESH_S = 90
4798
+ # The pulse's pools in its order (statusd.PULSE_POOLS, the throttle-pulse POOLS; W12 added kimi), named by the
4799
+ # `throttle pools:` line. A pool the pulse did not report says `?`: a pulse from before 1.5.0, or a daemon from before
4800
+ # its Kimi pool (`/pools/kimi/ready` answers 404 there).
4801
+ PULSE_POOLS = ('claude', 'codex', 'kimi')
4802
+ FLEET_FRESH_S = 60 # statusd republishes `fleet` every FLEET_INTERVAL (10 s) unless the host is critical
4803
+ THROTTLE_MONITOR = 'tools--throttle-monitor'
4804
+ FLEET_DASHBOARD_KEY = 'LANES_FLEET_DASHBOARD_URL'
4805
+ # The dashboard link (spec §1, §3): https only, a DNS host name, an optional port and a plain path — what an OSC 8 link
4806
+ # and a browser may be handed without escaping. No user part (no credential in a URL), no query or fragment (site.conf
4807
+ # lines carry neither: SITE_LINE refuses `?`, `#`, `=` and `&`). Accepted when an operator writes it into the server's
4808
+ # ~/.config/lanes/site.conf; no Lanes code path ever writes it (test_server_cli.FleetDashboardUrl pins both).
4809
+ FLEET_DASHBOARD_URL = re.compile(r'https://(?=[^/:]{1,253}(?::|/|\Z))[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
4810
+ r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*'
4811
+ r'(?::(?P<port>[0-9]{1,5}))?(?:/[A-Za-z0-9._~/-]*)?\Z', re.ASCII)
4812
+
4813
+
4814
+ def fleet_dashboard_url(home=None):
4815
+ """site.conf's LANES_FLEET_DASHBOARD_URL -> (url, problem). (None, None): unset — the state until an authenticated
4816
+ Grafana deployment exists; (url, None): accepted; (None, why): written but refused (not https, a user part, a port
4817
+ out of range, a character outside the plain set), so nothing links to it. The last line naming the key wins, as in
4818
+ site_profile; a line SITE_LINE does not admit (a `?` in it, a space) is judged here too rather than read as unset."""
4819
+ try:
4820
+ text = ((Path(home) if home is not None else Path.home()) / '.config/lanes/site.conf').read_text()
4821
+ except (OSError, UnicodeDecodeError):
4822
+ return None, None
4823
+ values = [line.split('=', 1)[1].strip() for line in text.splitlines()
4824
+ if line.startswith(FLEET_DASHBOARD_KEY + '=')]
4825
+ if not values or not values[-1]:
4826
+ return None, None
4827
+ value = values[-1]
4828
+ if not value.startswith('https://'):
4829
+ return None, 'not an https URL'
4830
+ match = FLEET_DASHBOARD_URL.match(value)
4831
+ if not match:
4832
+ return None, 'not a plain https://host[:port][/path] URL (no user part, query, fragment or space)'
4833
+ if match.group('port') is not None and not 0 < int(match.group('port')) < 65536:
4834
+ return None, 'port out of range'
4835
+ return value, None
4836
+
4837
+
4838
+ def throttle_container(registry=None, home=None):
4839
+ """(container, where it was named) of the Throttle daemon's container (and so the pulse's, spec §2.1), or (None,
4840
+ None): site.conf's LANES_THROTTLE_CONTAINER (site_profile, `home` for a test's), else the container of the
4841
+ registry's Throttle Monitor tab (`tools--throttle-monitor`; `registry` None reads <state>/registry.json). A name
4842
+ that is not an identifier names nothing. install-container (which starts the pulse), doctor and the update's
4843
+ re-ship (update.daemon_container) all read the setting from site.conf, so they name the same container."""
4844
+ named = site_profile(home).get('LANES_THROTTLE_CONTAINER')
4845
+ if named and L.IDENT.fullmatch(named):
4846
+ return named, 'site.conf LANES_THROTTLE_CONTAINER'
4847
+ if registry is None:
4848
+ registry = L.read_json(L.STATE / 'registry.json', {})
4849
+ for tool in (registry.get('tools') if isinstance(registry, dict) else None) or ():
4850
+ if isinstance(tool, dict) and tool.get('id') == THROTTLE_MONITOR and isinstance(tool.get('container'), str) \
4851
+ and L.IDENT.fullmatch(tool['container']):
4852
+ return tool['container'], f"the registry's {THROTTLE_MONITOR}"
4853
+ return None, None
4854
+
4855
+
4856
+ def age_shown(seconds):
4857
+ seconds = max(0, int(seconds))
4858
+ if seconds < 120:
4859
+ return f'{seconds} s'
4860
+ return f'{seconds // 60} min' if seconds < 7200 else f'{seconds // 3600} h'
4861
+
4862
+
4863
+ def number(value):
4864
+ """A finite JSON number, else None (a bool, a string, NaN or an infinity a hand-edited file could hold)."""
4865
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
4866
+
4867
+
4868
+ def pulse_line(throttle, container, now):
4869
+ """The pulse: its age by `at`, the container it runs in, whether the daemon answered and whether it holds the
4870
+ opt-in bearer (money and burn lines need it; `lanes throttle adopt-token <c>` is the operator's step)."""
4871
+ where = (f'container {container}' if container else
4872
+ 'no daemon container: site.conf LANES_THROTTLE_CONTAINER unset and no Throttle tab')
4873
+ adopt = f'lanes throttle adopt-token {container}' if container else 'lanes throttle adopt-token <container>'
4874
+ at = number((throttle or {}).get('at')) if isinstance(throttle, dict) else None
4875
+ if at is None:
4876
+ start = (f'lanes install-container {container} starts it in the daemon\'s container' if container else
4877
+ 'name the daemon\'s container with LANES_THROTTLE_CONTAINER in ~/.config/lanes/site.conf, then lanes '
4878
+ 'install-container <container>')
4879
+ return '⚠', f'throttle pulse: none in status.json ({where}) — {start}'
4880
+ age = now - at
4881
+ up = throttle.get('up') is True
4882
+ error = throttle.get('error')
4883
+ daemon = 'daemon up' if up else 'daemon down' + (f' ({plain(error)[:80]})' if isinstance(error, str) and error else '')
4884
+ tokened = throttle.get('tokened') is True
4885
+ text = (f'throttle pulse: {age_shown(age)} old ({where}), {daemon}, tokened: {"yes" if tokened else "no"}'
4886
+ + ('' if tokened else f' — {adopt}'))
4887
+ if throttle.get('stale') is True or age > PULSE_FRESH_S:
4888
+ rearm = f'lanes install-container {container}' if container else 'lanes install-container <container>'
4889
+ return '⚠', text + f'; stale: the pulse stopped (statusd re-arms it at most every 10 min, or {rearm})'
4890
+ return ('✓' if up else '⚠'), text
4891
+
4892
+
4893
+ def pools_line(throttle):
4894
+ """The pools the pulse reports, usable of configured: `throttle pools: claude 12/19 · codex 3/5 · kimi 5/8 usable`.
4895
+ A pool with nothing configured is `0/0` (a daemon with no Kimi account), one the pulse did not report `?`. A `⚠`
4896
+ when a configured pool has no usable account (the sidebar's attention tone); otherwise information. The pulse
4897
+ line above says how old these figures are."""
4898
+ pools = throttle.get('pools') if isinstance(throttle, dict) else None
4899
+ if not isinstance(pools, dict) or number(throttle.get('at')) is None:
4900
+ return '', 'throttle pools: none in status.json'
4901
+
4902
+ def count(value):
4903
+ return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
4904
+ parts, empty = [], []
4905
+ for pool in PULSE_POOLS:
4906
+ row = pools.get(pool) if isinstance(pools.get(pool), dict) else {}
4907
+ configured, usable = count(row.get('configured')), count(row.get('usable'))
4908
+ if configured is None:
4909
+ parts.append(f'{pool} ?')
4910
+ continue
4911
+ usable = min(usable or 0, configured)
4912
+ parts.append(f'{pool} {usable}/{configured}')
4913
+ if configured and not usable:
4914
+ empty.append(pool)
4915
+ text = 'throttle pools: ' + ' · '.join(parts) + ' usable'
4916
+ if empty:
4917
+ return '⚠', text + f" — no usable account in {', '.join(empty)}"
4918
+ return '', text
4919
+
4920
+
4921
+ def fleet_line(fleet, containers, now):
4922
+ """The host: cpu and memory utilization, the highest PSI avg10, and how many workspace containers run."""
4923
+ containers = containers if isinstance(containers, dict) else {}
4924
+ running = sum(1 for info in containers.values() if isinstance(info, dict) and info.get('running'))
4925
+ if not isinstance(fleet, dict) or number(fleet.get('at')) is None:
4926
+ return '⚠', (f'fleet: not in status.json (containers {running}) — a statusd from before 1.4.0 '
4927
+ '(systemctl --user restart lanes-statusd.service)')
4928
+
4929
+ def percent(value):
4930
+ value = number(value)
4931
+ return '?' if value is None else f'{round(value * 100)}%'
4932
+ pressure = fleet.get('system.pressure') if isinstance(fleet.get('system.pressure'), dict) else {}
4933
+ psi = [value for value in map(number, pressure.values()) if value is not None]
4934
+ text = (f"fleet: cpu {percent(fleet.get('system.cpu.utilization'))} · "
4935
+ f"mem {percent(fleet.get('system.memory.utilization'))} · "
4936
+ f"psi {f'{round(max(psi))}%' if psi else '?'} · containers {running}")
4937
+ age = now - fleet['at']
4938
+ if fleet.get('stale') is True or age > FLEET_FRESH_S:
4939
+ return '⚠', text + f' — stale ({age_shown(age)} old: statusd skips it while the host is critical)'
4940
+ return '', text
4941
+
4942
+
4943
+ def dashboard_line(home=None):
4944
+ url, problem = fleet_dashboard_url(home)
4945
+ if problem:
4946
+ return '⚠', f'fleet dashboard: {FLEET_DASHBOARD_KEY} ignored — {problem}; nothing links to it'
4947
+ if url is None:
4948
+ return '', f'fleet dashboard: {FLEET_DASHBOARD_KEY} unset'
4949
+ return '', f'fleet dashboard: {url}'
4950
+
4951
+
4952
+ def fleet_doctor(status, registry, home=None, now=None):
4953
+ """`lanes doctor`'s W11 lines -> [(mark, text)] (✓ | ⚠ | '' information; never ✗), in this order: the Throttle
4954
+ pulse, its pools (W12: claude, codex, kimi), the fleet, the dashboard link. Read-only: status.json (already
4955
+ read), the registry, site.conf."""
4956
+ now = time.time() if now is None else now
4957
+ status = status if isinstance(status, dict) else {}
4958
+ container, _named_by = throttle_container(registry, home)
4959
+ return [pulse_line(status.get('throttle'), container, now),
4960
+ pools_line(status.get('throttle')),
4961
+ fleet_line(status.get('fleet'), status.get('containers'), now),
4962
+ dashboard_line(home)]
4963
+
4964
+
4965
+ # ------------------------------------------------------------------ doctor: the harness mix (W12)
4966
+ # `agent.harness` is an observed fact since W12 (spec
4967
+ # docs/superpowers/specs/2026-10-15-lanes-harness-observers-design.md §2): statusd reads it from the pane's process
4968
+ # tree. doctor counts what status.json carries — every lane row with an `agent` object, by its harness — and names each
4969
+ # value of the spec's enum, `unknown` (and any value outside the enum, such as an empty harness from a statusd before
4970
+ # W12) only when some lane has it. A row without `agent` (no signal names an agent there) is not counted. Information
4971
+ # only, never a ✗.
4972
+ HARNESSES = ('claude', 'codex', 'kimi', 'grok', 'grok-cli', 'shell')
4973
+
4974
+
4975
+ def harness_line(status):
4976
+ """`lanes: claude 50 · codex 20 · kimi 3 · grok 0 · grok-cli 0 · shell 0` from status.json's lane rows. `shell`
4977
+ counts an agent{} that names it, which statusd's merge never writes (a pane whose harness ended is published under
4978
+ that harness's name with status `shell`, DM-LANES-STATUS): the slot reads 0 unless a foreign sidecar says so."""
4979
+ counts, unknown = dict.fromkeys(HARNESSES, 0), 0
4980
+ lanes = status.get('lanes') if isinstance(status, dict) else None
4981
+ for row in lanes.values() if isinstance(lanes, dict) else ():
4982
+ agent = row.get('agent') if isinstance(row, dict) else None
4983
+ if not isinstance(agent, dict):
4984
+ continue
4985
+ harness = agent.get('harness')
4986
+ if isinstance(harness, str) and harness in counts:
4987
+ counts[harness] += 1
4988
+ else:
4989
+ unknown += 1
4990
+ return '', ('lanes: ' + ' · '.join(f'{name} {n}' for name, n in counts.items())
4991
+ + (f' · unknown {unknown}' if unknown else ''))
4992
+
4993
+
3993
4994
  # ------------------------------------------------------------------ doctor: the release that runs (W9 V3, LNS-85)
3994
4995
  # `lanes version` says which tree each process last started from; doctor judges it, with the reference `lanes version`
3995
4996
  # uses (the tree the `current` link names; this tree when there is none). A run record outlives its process, so only a
@@ -4004,8 +5005,8 @@ def web_doctor():
4004
5005
  # <tree>/libexec/sidebar.py …`. Anything else that names the file — an editor, a pager, a grep, the script run by
4005
5006
  # hand another way — and a one-shot that never records (RUN_ONESHOTS) is not that daemon (DEF-1785). studio_host
4006
5007
  # records a oneshot step (install/enable-studio-host.sh), never a running process.
4007
- RUN_SCRIPTS = {'statusd': 'statusd.py', 'enrold': 'enrold.py', 'notify': 'notify.py', 'economy': 'economy_poll.py',
4008
- 'sidebar': 'sidebar.py'}
5008
+ RUN_SCRIPTS = {'statusd': 'statusd.py', 'enrold': 'enrold.py', 'docsd': 'docsd.py', 'notify': 'notify.py',
5009
+ 'economy': 'economy_poll.py', 'sidebar': 'sidebar.py'}
4009
5010
  RUN_INTERPRETER = re.compile(r'python3(?:\.\d+)?') # argv[0]'s name, by path or not: python3, a versioned python3.N
4010
5011
  RUN_FLAGS = ['-S', '-E'] # the interpreter flags of every launch, in this order
4011
5012
  # The arguments that make a run a one-shot that never records, as each record_tree comment names them. statusd reads
@@ -4013,8 +5014,8 @@ RUN_FLAGS = ['-S', '-E'] # the interpreter flags of
4013
5014
  # anywhere and by any prefix it can tell apart (`--dry`; none of these prefixes another option of its script).
4014
5015
  RUN_ONESHOTS = {'statusd': ('--once',), 'notify': ('--test',), 'economy': ('--once', '--dry-run')}
4015
5016
  RUN_FIRST_ARGUMENT = {'statusd'}
4016
- RUN_UNITS = {'statusd': 'lanes-statusd.service', 'enrold': 'lanes-enrold.service', 'notify': 'lanes-notify.service',
4017
- 'economy': 'lanes-economy.service'} # what restarts each daemon; a sidebar restarts with its view
5017
+ RUN_UNITS = {'statusd': 'lanes-statusd.service', 'enrold': 'lanes-enrold.service', 'docsd': 'lanes-docsd.service',
5018
+ 'notify': 'lanes-notify.service', 'economy': 'lanes-economy.service'} # what restarts each daemon; a sidebar restarts with its view
4018
5019
  LAYOUT_COMMAND = re.compile(r'command=("(?:[^"\\]|\\.)*")') # q(): a JSON string
4019
5020
  LAYOUT_FILES = L.LAYOUT_FILES # the tree files a generated layout runs
4020
5021
  UNIT_ENTRY = re.compile(r'"@REPO@/([^"]+)"') # a unit template's tree file (install/services.py)
@@ -4319,6 +5320,8 @@ def cmd_doctor(_args):
4319
5320
  # 'unknown' / stale = statusd could not OBSERVE the lane (its tmux server did not answer): not the same as gone.
4320
5321
  check(not states.get('missing') and not states.get('unknown') and not stale, 'all registry lanes observed and present',
4321
5322
  str(states) + (f'; {stale} shown with their last known state (tmux server did not answer)' if stale else ''))
5323
+ # The harness mix (W12): which agent each lane runs, as statusd observed it; information only.
5324
+ print(f' {harness_line(status)[1]}')
4322
5325
  # One bounded process-table sweep serves both checks; the cgroup of the few tmux servers is a plain file read.
4323
5326
  rc, table, _err = L.run_bounded(['ps', '-eo', 'pid=,comm=,args='], 10)
4324
5327
  coexist = (L.STATE / 'flags/coexist').exists()
@@ -4370,6 +5373,18 @@ def cmd_doctor(_args):
4370
5373
  print(f'⚠ {text}')
4371
5374
  else:
4372
5375
  check(mark == '✓', text)
5376
+ # The docs service (W10 W8, LNS-109): lanes-docsd and its port, the pin gate, the store, the Mermaid pin, `age`.
5377
+ for mark, text in docs_doctor():
5378
+ if mark in ('⚠', ''):
5379
+ print(f'{mark or " "} {text}')
5380
+ else:
5381
+ check(mark == '✓', text)
5382
+ # The fleet and the Throttle pulse (W11): what statusd published and site.conf's dashboard link; never a ✗.
5383
+ for mark, text in fleet_doctor(status, registry):
5384
+ if mark in ('⚠', ''):
5385
+ print(f'{mark or " "} {text}')
5386
+ else:
5387
+ check(mark == '✓', text)
4373
5388
  # The release that runs (W9 V3): `current`, the run records against the one process-table sweep above, the
4374
5389
  # device layouts, the units.
4375
5390
  for mark, text in release_doctor(table if rc == 0 else None):
@@ -5048,12 +6063,12 @@ COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_cli
5048
6063
  'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
5049
6064
  'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
5050
6065
  'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, 'unregister': cmd_unregister,
5051
- 'hooks': cmd_hooks, 'economy': cmd_economy,
6066
+ 'hooks': cmd_hooks, 'economy': cmd_economy, 'throttle': cmd_throttle,
5052
6067
  'preview': cmd_preview, 'edit': cmd_edit, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev,
5053
6068
  'sidebar-key': cmd_sidebar_key, 'studio-host': cmd_studio_host,
5054
6069
  'keys': cmd_keys, 'invite': cmd_invite, 'enrol': cmd_enrol, 'offboard': cmd_offboard,
5055
6070
  'token': cmd_token, 'onboard': cmd_onboard, 'user': cmd_user, 'lane': cmd_lane, 'here': cmd_here,
5056
- 'version': cmd_version, 'update': cmd_update}
6071
+ 'version': cmd_version, 'update': cmd_update, 'docs': cmd_docs}
5057
6072
 
5058
6073
  if __name__ == '__main__':
5059
6074
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: