@vibelord/lanes 1.3.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.
@@ -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,
@@ -192,15 +199,34 @@
192
199
  (additive; installs no agent hook and no editor: lane-edit runs $LANES_EDITOR or the
193
200
  first of micro hx nvim vim vi there) and add the Lanes MCP server to the agents'
194
201
  configs: mcpServers.lanes in ~/.claude.json and [mcp_servers.lanes] in
195
- ~/.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,
196
206
  the file kept as <file>.bak-<UTC> first, atomic (a `lanes` entry that starts something
197
207
  else is left alone, exit 1; on a uid split nothing is written there, exit 1). --no-mcp
198
208
  leaves out lane-mcp and the entries (lane-edit and its vimrc still ship) and removes
199
- nothing an earlier install wrote. In the container: lane mcp install
200
- 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]
201
222
  merge the lane-signal hooks into that home's ~/.claude/settings.json or ~/.codex/hooks.json
202
- (+ a top-level Codex notify when none is set); prints the diff, writes only with --write
203
- (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.
204
230
  --write into a --container whose home the agent could not read from this account (uid
205
231
  not 1000, or a home volume not owned by 1000) is refused with the reason
206
232
  lanes invariant save|check snapshot / verify every live pane's pid and window size
@@ -360,6 +386,7 @@
360
386
  """
361
387
  import contextlib
362
388
  import json
389
+ import math
363
390
  import os
364
391
  import posixpath
365
392
  import pwd
@@ -1185,7 +1212,15 @@ def hook_env(home):
1185
1212
 
1186
1213
  INSTALL_CONTAINER_USAGE = 'usage: lanes install-container <name> [--no-mcp]'
1187
1214
  # lane_core.py first: `lane` imports it, so no moment has a new `lane` beside an old (or no) lane_core.py.
1188
- 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'
1189
1224
 
1190
1225
 
1191
1226
  def lane_core():
@@ -1285,8 +1320,8 @@ def cmd_install_container(args):
1285
1320
  # user (0700), never here.
1286
1321
  install_file(L.REPO / 'container/vimrc', root / 'vimrc')
1287
1322
  # The hook DEFINITIONS travel with the contract for reference; no hook is installed here — that is the opt-in,
1288
- # per-container `lanes hooks install --agent claude|codex --container <name> --write` (RUNBOOK).
1289
- 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')]):
1290
1325
  install_file(source, root / 'hooks' / source.name)
1291
1326
  env_file = L.REPO / 'container/env.sh' # TMPDIR off the RAM-backed /tmp; only NEW shells and agents read it
1292
1327
  if env_file.exists():
@@ -1301,21 +1336,142 @@ def cmd_install_container(args):
1301
1336
  if link.is_symlink() or link.exists():
1302
1337
  link.unlink()
1303
1338
  link.symlink_to(root)
1304
- 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-*}}, "
1305
1341
  f'{root}/VERSION, {root}/vimrc, {root}/hooks/' + (f' and {root}/env.sh' if env_file.exists() else '')
1306
1342
  + f'; host link {link} -> {root}')
1343
+ pulse = start_pulse(container)
1307
1344
  if no_mcp:
1308
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}')
1309
1380
  return 0
1310
- return install_mcp_entries(home)
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})')
1384
+ return 0
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
1311
1455
 
1312
1456
 
1313
1457
  HOOK_TARGETS = {'claude': ('claude-settings.json', '.claude/settings.json'),
1314
- 'codex': ('codex-hooks.json', '.codex/hooks.json')}
1315
- 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>) '
1316
1467
  '[--dry-run | --write]')
1317
1468
 
1318
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
+
1319
1475
  def render_hooks(agent):
1320
1476
  """The shipped hook definitions with `~/` spelled as the container home: the command reaches lane-signal whether
1321
1477
  or not the harness runs it through a shell that expands `~`."""
@@ -1377,16 +1533,236 @@ def codex_notify(text):
1377
1533
  + json.dumps(argv) + '\n' + text)
1378
1534
 
1379
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
+
1380
1551
  def show_change(path, before, after):
1381
1552
  import difflib
1382
1553
  if before == after:
1383
1554
  print(f'{path}: no change')
1384
1555
  return False
1385
- sys.stdout.writelines(difflib.unified_diff(before.splitlines(True), after.splitlines(True),
1386
- 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)'))
1387
1558
  return True
1388
1559
 
1389
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
+
1390
1766
  def write_with_backup(path, before, after):
1391
1767
  """Atomic replace, the previous bytes kept beside it first. `before` is what the diff was computed from."""
1392
1768
  if path.exists():
@@ -1402,6 +1778,9 @@ def write_with_backup(path, before, after):
1402
1778
  def cmd_hooks(args):
1403
1779
  """`lanes hooks install`: merge the lane-signal hook definitions into ONE container home's agent config. Dry run
1404
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).
1405
1784
  A --container target on a uid split (`uid_split`: this account or the home volume's owner is not the container
1406
1785
  uid) is refused with --write — the agent could not read the 0600 file this account would create — and warned in a
1407
1786
  dry run (DEF-1727). `--home <dir>` is for trying the merge on a copy, not an agent's home, and is not checked."""
@@ -1433,6 +1812,18 @@ def cmd_hooks(args):
1433
1812
  if split and opts.get('--write'):
1434
1813
  raise L.LanesError(f"{split}: the agent (uid {L.CONTAINER_UID}) could not read what this account would write "
1435
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
1436
1827
  target = home / HOOK_TARGETS[agent][1]
1437
1828
  before = target.read_text() if target.exists() else ''
1438
1829
  try:
@@ -2351,7 +2742,9 @@ def cmd_workspaces(args):
2351
2742
  # ------------------------------------------------------------------- onboard
2352
2743
 
2353
2744
  ONBOARD_USAGE = ('usage: lanes onboard <login> --container <name> [--workspace <id> --repo <path> [--title T]] '
2354
- '[--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'}
2355
2748
  # The receipt's status vocabulary. A dry run reports planned, unchanged, skipped, manual or failed, and `ok` for the
2356
2749
  # read-only `inspect` of the container; `manual` names a step a person still has to take (printed with the exact command);
2357
2750
  # `warn` is a step that ran and reported a problem that does not stop onboarding (the doctor's verdict, a board with
@@ -2372,7 +2765,7 @@ THROTTLE_PROBE = ('if ! command -v throttle >/dev/null; then echo absent; '
2372
2765
  THROTTLE_SETUP = 'throttle setup --install-clients'
2373
2766
  THROTTLE_PROBE_TIMEOUT = 20
2374
2767
  THROTTLE_TIMEOUT = 120
2375
- 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
2376
2769
 
2377
2770
 
2378
2771
  def onboard_step(name, status, detail=''):
@@ -2394,13 +2787,10 @@ def uid_split(home):
2394
2787
  return None
2395
2788
 
2396
2789
 
2397
- def site_profile():
2398
- """This server's site facts: the LANES_* KEY=VALUE lines of ~/.config/lanes/site.conf (read, never executed)."""
2399
- try:
2400
- text = (Path.home() / '.config/lanes/site.conf').read_text()
2401
- except OSError:
2402
- return {}
2403
- 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)
2404
2794
 
2405
2795
 
2406
2796
  def provision_throttle(container, site, write=True):
@@ -2465,13 +2855,134 @@ def provision_laptop(steps, site):
2465
2855
  return steps
2466
2856
 
2467
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
+
2468
2977
  def onboard_args(args):
2469
2978
  if not args or args[0].startswith('-'):
2470
2979
  raise L.LanesError(ONBOARD_USAGE)
2471
2980
  opts, rest = {}, list(args[1:])
2472
2981
  while rest:
2473
2982
  word = rest.pop(0)
2474
- 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:
2475
2986
  opts[word] = rest.pop(0)
2476
2987
  elif word in ('--write', '--no-mcp'):
2477
2988
  opts[word] = True
@@ -2613,10 +3124,20 @@ def cmd_onboard(args):
2613
3124
  if lane_core().read_regular(path, lane_core().EDIT_MAX) != mcp_before.get(path)]
2614
3125
  return ('changed', ', '.join(changed)) if changed else ('unchanged', files)
2615
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
+
2616
3134
  def hooks_step(agent):
2617
3135
  def step():
2618
3136
  target = home()
2619
- 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)]
2620
3141
  before = [path.read_bytes() if path.exists() else None for path in files]
2621
3142
  cmd_hooks(['install', '--agent', agent, '--container', container, '--write' if write else '--dry-run'])
2622
3143
  if not write:
@@ -2626,6 +3147,24 @@ def cmd_onboard(args):
2626
3147
  return ('changed', ', '.join(changed)) if changed else ('unchanged', str(files[0]))
2627
3148
  return step
2628
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
+
2629
3168
  def sync_step():
2630
3169
  if not write:
2631
3170
  print('would run: lanes sync')
@@ -2679,9 +3218,13 @@ def cmd_onboard(args):
2679
3218
 
2680
3219
  print(f"lanes onboard {login} -> container {container}" + (f', board {board}' if board else '')
2681
3220
  + ('' if write else ' (dry run)'))
2682
- 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),
2683
3225
  ('lane-mcp', mcp_step, False), ('hooks-claude', hooks_step('claude'), False),
2684
- ('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),
2685
3228
  ('throttle', lambda: provision_throttle(container, site, write), False),
2686
3229
  ('sync', sync_step, False), ('cursor-sync', cursor_step, False), ('resync', resync_step, False),
2687
3230
  ('doctor', doctor_step, False)]
@@ -2697,7 +3240,8 @@ def cmd_onboard(args):
2697
3240
  for key, value in (('login', login), ('container', container), ('workspace', board or '-')):
2698
3241
  print(f'{key + ":":<14}{value}')
2699
3242
  for step in steps:
2700
- 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']}")
2701
3245
  if write:
2702
3246
  print(f"{'receipt:':<14}{onboard_receipt(login, container, board, steps)}")
2703
3247
  else:
@@ -4240,6 +4784,213 @@ def docs_doctor(home=None, probe=None):
4240
4784
  docs_mermaid_line(home), docs_age_line()]
4241
4785
 
4242
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
+
4243
4994
  # ------------------------------------------------------------------ doctor: the release that runs (W9 V3, LNS-85)
4244
4995
  # `lanes version` says which tree each process last started from; doctor judges it, with the reference `lanes version`
4245
4996
  # uses (the tree the `current` link names; this tree when there is none). A run record outlives its process, so only a
@@ -4569,6 +5320,8 @@ def cmd_doctor(_args):
4569
5320
  # 'unknown' / stale = statusd could not OBSERVE the lane (its tmux server did not answer): not the same as gone.
4570
5321
  check(not states.get('missing') and not states.get('unknown') and not stale, 'all registry lanes observed and present',
4571
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]}')
4572
5325
  # One bounded process-table sweep serves both checks; the cgroup of the few tmux servers is a plain file read.
4573
5326
  rc, table, _err = L.run_bounded(['ps', '-eo', 'pid=,comm=,args='], 10)
4574
5327
  coexist = (L.STATE / 'flags/coexist').exists()
@@ -4626,6 +5379,12 @@ def cmd_doctor(_args):
4626
5379
  print(f'{mark or " "} {text}')
4627
5380
  else:
4628
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)
4629
5388
  # The release that runs (W9 V3): `current`, the run records against the one process-table sweep above, the
4630
5389
  # device layouts, the units.
4631
5390
  for mark, text in release_doctor(table if rc == 0 else None):
@@ -5304,7 +6063,7 @@ COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_cli
5304
6063
  'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
5305
6064
  'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
5306
6065
  'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, 'unregister': cmd_unregister,
5307
- 'hooks': cmd_hooks, 'economy': cmd_economy,
6066
+ 'hooks': cmd_hooks, 'economy': cmd_economy, 'throttle': cmd_throttle,
5308
6067
  'preview': cmd_preview, 'edit': cmd_edit, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev,
5309
6068
  'sidebar-key': cmd_sidebar_key, 'studio-host': cmd_studio_host,
5310
6069
  'keys': cmd_keys, 'invite': cmd_invite, 'enrol': cmd_enrol, 'offboard': cmd_offboard,