@vibelord/lanes 1.3.0 → 1.5.1

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
@@ -1100,8 +1127,8 @@ def cmd_attach(args):
1100
1127
  # Record the base and bring the owner (W8 A2) in line, also for sessions that existed before either was known.
1101
1128
  record_base(device, args)
1102
1129
  record_look(device, args)
1103
- # Views idle until a real client (with a real size) has attached once. We exec into the zellij
1104
- # client below, so a detached helper raises the flag shortly after the client has sized the session.
1130
+ # Views idle until a real client (with a real size) has attached once. We exec into the display
1131
+ # supervisor below, so a detached helper raises the flag shortly after its client has sized the session.
1105
1132
  ready = L.STATE / 'clients' / f'{device}.ready'
1106
1133
  if not ready.exists() and os.fork() == 0:
1107
1134
  os.setsid()
@@ -1136,7 +1163,11 @@ def cmd_attach(args):
1136
1163
  'COLORTERM': os.environ.get('COLORTERM', 'truecolor')})
1137
1164
  if L.run_bounded(['infocmp', env['TERM']], 5)[0] != 0: # unknown here — or no answer: the safe default
1138
1165
  env['TERM'] = 'xterm-256color'
1139
- os.execve(str(ZELLIJ), ZELLIJ_BASE + ['attach', device], env)
1166
+ # Own the terminal backlog as well as SSH's transport. A slow Zellij 0.45.1
1167
+ # client can stay alive after its server has dropped it (Exit is behind the
1168
+ # queued frames); keepalives alone cannot make lanes-launch reconnect it.
1169
+ os.execve(sys.executable, [sys.executable, str(L.REPO / 'libexec/attach.py'),
1170
+ *ZELLIJ_BASE, 'attach', device], env)
1140
1171
 
1141
1172
 
1142
1173
  def cmd_open(args):
@@ -1185,7 +1216,15 @@ def hook_env(home):
1185
1216
 
1186
1217
  INSTALL_CONTAINER_USAGE = 'usage: lanes install-container <name> [--no-mcp]'
1187
1218
  # 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')
1219
+ CONTRACT_FILES = ('lane_core.py', 'lane-mcp', 'lane', 'lane-run', 'lane-signal', 'lane-edit', 'throttle-pulse')
1220
+ # The Throttle pulse (W11): installed everywhere, its loop started only in the daemon's container. As uid 1000 with
1221
+ # the container home as HOME, whatever user the image defaults to: it writes ~/.lz/state/throttle.json, which
1222
+ # lanes-statusd reads, and reads ~/.lz/throttle-token, which only the container's own user may read (0600).
1223
+ PULSE_BIN = L.CONTAINER_HOME + '/.lz/bin/throttle-pulse'
1224
+ PULSE_TMUX = ('tmux', '-L', 'lanes-pulse')
1225
+ PULSE_SESSION = 'pulse'
1226
+ PULSE_DOCKER_S = 20
1227
+ THROTTLE_MONITOR = 'tools--throttle-monitor'
1189
1228
 
1190
1229
 
1191
1230
  def lane_core():
@@ -1285,8 +1324,8 @@ def cmd_install_container(args):
1285
1324
  # user (0700), never here.
1286
1325
  install_file(L.REPO / 'container/vimrc', root / 'vimrc')
1287
1326
  # 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')):
1327
+ # per-container `lanes hooks install --agent claude|codex|kimi|grok|grok-cli --container <name> --write` (RUNBOOK).
1328
+ for source in sorted([*(L.REPO / 'container/hooks').glob('*.json'), *(L.REPO / 'container/hooks').glob('*.toml')]):
1290
1329
  install_file(source, root / 'hooks' / source.name)
1291
1330
  env_file = L.REPO / 'container/env.sh' # TMPDIR off the RAM-backed /tmp; only NEW shells and agents read it
1292
1331
  if env_file.exists():
@@ -1301,21 +1340,142 @@ def cmd_install_container(args):
1301
1340
  if link.is_symlink() or link.exists():
1302
1341
  link.unlink()
1303
1342
  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-*}}, "
1343
+ print(f"installed {root}/bin/{{lane,lane_core.py,lane-run,lane-signal,lane-edit,throttle-pulse"
1344
+ f"{'' if no_mcp else ',lane-mcp'},zmx-*}}, "
1305
1345
  f'{root}/VERSION, {root}/vimrc, {root}/hooks/' + (f' and {root}/env.sh' if env_file.exists() else '')
1306
1346
  + f'; host link {link} -> {root}')
1347
+ pulse = start_pulse(container)
1307
1348
  if no_mcp:
1308
1349
  print('--no-mcp: lane-mcp and the agents\' MCP entries left out (anything an earlier install wrote is kept)')
1350
+ return pulse
1351
+ return install_mcp_entries(home) or pulse
1352
+
1353
+
1354
+ def throttle_container():
1355
+ """(container, where it was named) of the Throttle daemon's container, or (None, None): site.conf's
1356
+ LANES_THROTTLE_CONTAINER, else the container of the registry's Throttle Monitor tool (`lanes sync` names the one
1357
+ that hosts most boards' monitor)."""
1358
+ named = site_profile().get('LANES_THROTTLE_CONTAINER')
1359
+ if named and L.IDENT.match(named):
1360
+ return named, 'site.conf LANES_THROTTLE_CONTAINER'
1361
+ registry = L.read_json(L.STATE / 'registry.json', {})
1362
+ for tool in (registry.get('tools') if isinstance(registry, dict) else None) or []:
1363
+ if isinstance(tool, dict) and tool.get('id') == THROTTLE_MONITOR and L.IDENT.match(str(tool.get('container'))):
1364
+ return tool['container'], f'the registry\'s {THROTTLE_MONITOR}'
1365
+ return None, None
1366
+
1367
+
1368
+ def pulse_argv(container, *tmux):
1369
+ """`docker exec` of the pulse's tmux server in `container` as uid 1000, argv only (no shell anywhere)."""
1370
+ detach = ['-d'] if tmux[:1] == ('new',) else []
1371
+ return ['docker', 'exec', *detach, '-u', str(L.CONTAINER_UID), '-e', f'HOME={L.CONTAINER_HOME}', container,
1372
+ *PULSE_TMUX, *tmux]
1373
+
1374
+
1375
+ def start_pulse(container):
1376
+ """Start the Throttle pulse's loop in `container` when it is the daemon's container (throttle_container) and no
1377
+ `pulse` session runs on the `lanes-pulse` tmux socket there -> exit code (1 only when docker refused the start).
1378
+ Any other container gets the file only."""
1379
+ daemon, named_by = throttle_container()
1380
+ if container != daemon:
1381
+ why = f'the daemon runs in {daemon} ({named_by})' if daemon else \
1382
+ 'no daemon container is known (site.conf LANES_THROTTLE_CONTAINER, or a board with a Throttle monitor)'
1383
+ print(f'throttle pulse: installed, not started here — {why}')
1309
1384
  return 0
1310
- return install_mcp_entries(home)
1385
+ rc, _out, _err = L.run_bounded(pulse_argv(container, 'has-session', '-t', f'={PULSE_SESSION}'), PULSE_DOCKER_S)
1386
+ if rc == 0:
1387
+ print(f'throttle pulse: already running in {container} (tmux -L lanes-pulse, session {PULSE_SESSION})')
1388
+ return 0
1389
+ rc, _out, err = L.run_bounded(pulse_argv(container, 'new', '-d', '-s', PULSE_SESSION, PULSE_BIN, '--loop'),
1390
+ PULSE_DOCKER_S)
1391
+ if rc != 0:
1392
+ last = next((line.strip() for line in reversed(err.splitlines()) if line.strip()), '')
1393
+ why = f'no answer within {PULSE_DOCKER_S} s' if rc is None else f'exit {rc}'
1394
+ print(f'! throttle pulse: not started in {container} ({why}' + (f': {last[:160]}' if last else '') + ')')
1395
+ return 1
1396
+ print(f'throttle pulse: started in {container} ({named_by}): tmux -L lanes-pulse, session {PULSE_SESSION}; '
1397
+ 'it writes ~/.lz/state/throttle.json every 10 s')
1398
+ return 0
1399
+
1400
+
1401
+ THROTTLE_USAGE = 'usage: lanes throttle adopt-token <container>'
1402
+ THROTTLE_TOKEN = re.compile(r'throttle_[A-Za-z0-9_-]{8,512}')
1403
+ SETTINGS_MAX = 1 << 20
1404
+
1405
+
1406
+ def cmd_throttle(args):
1407
+ """`lanes throttle adopt-token <container>`: the operator's opt-in to the pulse's tokened reads. Copies
1408
+ env.ANTHROPIC_API_KEY of the container's own ~/.claude/settings.json — the local Throttle client bearer
1409
+ `throttle setup --install-clients` put there, never a provider credential: it must look like `throttle_…` — into
1410
+ ~/.lz/throttle-token (0600, by rename), both read and written through /proc/<container init>/root without
1411
+ following a link. Never prints the value, not even in a refusal."""
1412
+ if len(args) != 2 or args[0] != 'adopt-token' or args[1].startswith('-'):
1413
+ raise L.LanesError(THROTTLE_USAGE)
1414
+ container = args[1]
1415
+ if not L.IDENT.match(container):
1416
+ raise L.LanesError(f'Invalid container name: {container!r}')
1417
+ info = L.container_info(container)
1418
+ if not info.get('running') or not info.get('pid'):
1419
+ raise L.LanesError(f'{container} is not running: nothing adopted')
1420
+ split = uid_split(info['home']) if info.get('home') else None
1421
+ if split:
1422
+ raise L.LanesError(f'{split}: the pulse (uid {L.CONTAINER_UID}) could not read a token this account writes — '
1423
+ 'nothing adopted')
1424
+ root = f"{L.proc_root()}/{int(info['pid'])}/root"
1425
+ home = L.CONTAINER_HOME.strip('/').split('/')
1426
+ try:
1427
+ fd = L.open_real_dir(root, [*home, '.claude'])
1428
+ except OSError:
1429
+ raise L.LanesError(f'{container}: no ~/.claude directory (a real one, not a link): nothing adopted') from None
1430
+ try:
1431
+ data = L.read_regular_at(fd, 'settings.json', SETTINGS_MAX)
1432
+ finally:
1433
+ os.close(fd)
1434
+ if data is None:
1435
+ raise L.LanesError(f'{container}: ~/.claude/settings.json is not a regular file of at most 1 MiB: '
1436
+ 'nothing adopted')
1437
+ try:
1438
+ settings = json.loads(data)
1439
+ except ValueError:
1440
+ raise L.LanesError(f'{container}: ~/.claude/settings.json is not JSON: nothing adopted') from None
1441
+ env = settings.get('env') if isinstance(settings, dict) else None
1442
+ value = env.get('ANTHROPIC_API_KEY') if isinstance(env, dict) else None
1443
+ if not isinstance(value, str) or not THROTTLE_TOKEN.fullmatch(value):
1444
+ raise L.LanesError(f'{container}: env.ANTHROPIC_API_KEY in ~/.claude/settings.json is not a local Throttle '
1445
+ 'client token (throttle_…): nothing adopted — run `throttle setup --install-clients` in '
1446
+ 'the container first')
1447
+ try:
1448
+ fd = L.open_real_dir(root, [*home, '.lz'])
1449
+ except OSError:
1450
+ raise L.LanesError(f'{container}: no ~/.lz directory (a real one): run `lanes install-container '
1451
+ f'{container}` first — nothing adopted') from None
1452
+ try:
1453
+ changed = L.atomic_write_if_changed_at(fd, 'throttle-token', value + '\n', 0o600)
1454
+ finally:
1455
+ os.close(fd)
1456
+ print(f'adopted: {container} ~/.lz/throttle-token (0600)' + ('' if changed else ', unchanged')
1457
+ + '; the pulse reads it on its next cycle')
1458
+ return 0
1311
1459
 
1312
1460
 
1313
1461
  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>) '
1462
+ 'codex': ('codex-hooks.json', '.codex/hooks.json'),
1463
+ 'kimi': ('kimi-hooks.toml', '.kimi-code/config.toml'),
1464
+ 'grok': ('grok-hooks.json', '.grok-build/hooks/lanes.json'),
1465
+ 'grok-cli': ('grok-cli-hooks.json', '.grok/user-settings.json')}
1466
+ # The other file an install changes: Codex's top-level notify; Grok Build's config.toml blocks (W12).
1467
+ HOOK_EXTRA = {'codex': ('.codex/config.toml',), 'grok': ('.grok-build/config.toml',)}
1468
+ # W12: a harness whose home is not a directory in the container is not installed there; `lanes onboard` skips it.
1469
+ HOOK_HOMES = {'kimi': '.kimi-code', 'grok': '.grok-build', 'grok-cli': '.grok'}
1470
+ HOOKS_USAGE = ('usage: lanes hooks install --agent claude|codex|kimi|grok|grok-cli (--container <name> | --home <dir>) '
1316
1471
  '[--dry-run | --write]')
1317
1472
 
1318
1473
 
1474
+ def hook_files(agent):
1475
+ """The container-home-relative files `lanes hooks install --agent <agent>` may change."""
1476
+ return (HOOK_TARGETS[agent][1],) + HOOK_EXTRA.get(agent, ())
1477
+
1478
+
1319
1479
  def render_hooks(agent):
1320
1480
  """The shipped hook definitions with `~/` spelled as the container home: the command reaches lane-signal whether
1321
1481
  or not the harness runs it through a shell that expands `~`."""
@@ -1377,16 +1537,236 @@ def codex_notify(text):
1377
1537
  + json.dumps(argv) + '\n' + text)
1378
1538
 
1379
1539
 
1540
+ SECRET_LINE = re.compile(r'^(\s*"?[A-Za-z0-9_.-]*(?:key|token|secret|password|credential)[A-Za-z0-9_.-]*"?\s*[:=]\s*)'
1541
+ r'(.+?)(,?)$', re.I)
1542
+
1543
+
1544
+ def redact_line(line):
1545
+ """A diff line whose key names a key, token, secret, password or credential, with its value shown as `…`: the
1546
+ agents' files hold credentials (grok-cli's `apiKey`, Claude's `env.ANTHROPIC_API_KEY`), and a dry run's diff is
1547
+ printed — and the onboarding printout pasted into tickets."""
1548
+ body = line.rstrip('\n')
1549
+ found = SECRET_LINE.match(body[1:]) if body[:1] in (' ', '+', '-') else None
1550
+ if not found:
1551
+ return line
1552
+ return body[0] + found.group(1) + '"…"' + found.group(3) + line[len(body):]
1553
+
1554
+
1380
1555
  def show_change(path, before, after):
1381
1556
  import difflib
1382
1557
  if before == after:
1383
1558
  print(f'{path}: no change')
1384
1559
  return False
1385
- sys.stdout.writelines(difflib.unified_diff(before.splitlines(True), after.splitlines(True),
1386
- f'{path} (now)', f'{path} (with lanes hooks)'))
1560
+ sys.stdout.writelines(redact_line(line) for line in difflib.unified_diff(
1561
+ before.splitlines(True), after.splitlines(True), f'{path} (now)', f'{path} (with lanes hooks)'))
1387
1562
  return True
1388
1563
 
1389
1564
 
1565
+ # ------------------------------------------------------------------- W12: Kimi Code, Grok Build and grok-cli hooks
1566
+ # Each file is changed through lane_core.edit_file: re-read before the write, `<file>.bak-<UTC>` first, atomic, never
1567
+ # through a symbolic link below the container home. A TOML file gets ONE marker block (lane_core.toml_block); nothing
1568
+ # outside it is rewritten. A refusal is raised to the operator as LanesError before anything is written.
1569
+
1570
+ GROK_CONFIG_BODY = ('[compat.claude]\n'
1571
+ "# Claude Code's hooks (lane-signal --harness claude) and MCP entry must not run inside Grok Build\n"
1572
+ 'hooks = false\n'
1573
+ 'mcps = false\n'
1574
+ '\n'
1575
+ '[ui.notifications]\n'
1576
+ "# a BEL on every turn end, approval and error, focused or not: the pane's window_bell_flag\n"
1577
+ 'condition = "always"\n'
1578
+ 'method = "bel"\n'
1579
+ 'events = ["turn_complete", "approval_required", "task_complete", "agent_error"]\n')
1580
+ GROK_CONFIG_VALUES = {'compat': {'claude': {'hooks': False, 'mcps': False}},
1581
+ 'ui': {'notifications': {'condition': 'always', 'method': 'bel',
1582
+ 'events': ['turn_complete', 'approval_required', 'task_complete',
1583
+ 'agent_error']}}}
1584
+ # The lanes trace on Grok Build's inference requests: each built-in model gains an env-sourced header (an override of a
1585
+ # built-in keeps its other fields; the value is read from $LANES_TRACE when the client is built, never stored).
1586
+ GROK_TRACE_MODELS = ('grok-4.6', 'grok-4.5')
1587
+ GROK_TRACE_HEADERS = {'x-throttle-trace': 'LANES_TRACE'}
1588
+
1589
+
1590
+ def _lane_error(error):
1591
+ return L.LanesError(str(error))
1592
+
1593
+
1594
+ def kimi_hooks_block():
1595
+ """The body of the shipped kimi-hooks.toml (between its marker lines), `~/` spelled as the container home."""
1596
+ rows = (L.REPO / 'container/hooks/kimi-hooks.toml').read_text().splitlines(True)
1597
+ return ''.join(rows[1:-1]).replace('"~/', f'"{L.CONTAINER_HOME}/')
1598
+
1599
+
1600
+ def _ours_hook(item):
1601
+ return isinstance(item, dict) and '/.lz/bin/lane-signal' in str(item.get('command') or '')
1602
+
1603
+
1604
+ def _toml_multiset(items):
1605
+ return sorted(json.dumps(item, sort_keys=True) for item in items)
1606
+
1607
+
1608
+ def kimi_config(text):
1609
+ """~/.kimi-code/config.toml with the lanes `[[hooks]]` block (kimi-hooks.toml) — Kimi Code reads hooks only from
1610
+ $KIMI_CODE_HOME/config.toml (`[[hooks]]` {event, matcher?, command, timeout?}, a strict schema; MEASURED in the
1611
+ 2.1.1 binary, 2026-10-15). The `.kimi-code` no-touch doctrine of the session registry concerns the device identity
1612
+ files, not this block. A `hooks` that is not an array of tables refuses; another writer's `[[hooks]]` stay."""
1613
+ core, what = lane_core(), '~/.kimi-code/config.toml'
1614
+ try:
1615
+ before = core._toml_loads(text or '', what)
1616
+ rest_text = core.without_block(text, 'hooks')
1617
+ after = core.toml_block(text, 'hooks', kimi_hooks_block(), core._lanes_command)
1618
+ except core.LaneError as error:
1619
+ raise _lane_error(f'{what}: {error}') from None
1620
+ if before is None: # no tomllib: the forms a textual rule can read
1621
+ if re.search(r'(?m)^[ \t]*hooks[ \t]*[=.]|^[ \t]*\[[ \t]*hooks[ \t]*\]', rest_text):
1622
+ raise L.LanesError(f'{what} defines `hooks` other than as [[hooks]] tables — left alone; fix it by hand')
1623
+ return after
1624
+ hooks = before.get('hooks', [])
1625
+ if not isinstance(hooks, list) or not all(isinstance(item, dict) for item in hooks):
1626
+ raise L.LanesError(f'{what}: "hooks" is not an array of tables ([[hooks]]) — left alone; fix it by hand')
1627
+ try:
1628
+ parsed = core._toml_loads(after, what)
1629
+ except core.LaneError:
1630
+ parsed = None
1631
+ expected = [item for item in hooks if not _ours_hook(item)] + core._toml_loads(kimi_hooks_block(), what)['hooks']
1632
+ if parsed is None or {k: v for k, v in parsed.items() if k != 'hooks'} != \
1633
+ {k: v for k, v in before.items() if k != 'hooks'} or \
1634
+ _toml_multiset(parsed.get('hooks', [])) != _toml_multiset(expected):
1635
+ raise L.LanesError(f'{what}: the hooks block could not be written without changing anything else — add it by '
1636
+ 'hand from ~/.lz/hooks/kimi-hooks.toml')
1637
+ return after
1638
+
1639
+
1640
+ def grok_hooks_file(_text):
1641
+ """$GROK_HOME/hooks/lanes.json: the whole file is Lanes' (grok-hooks.json, Claude Code's JSON shape, which Grok
1642
+ Build reads from every $GROK_HOME/hooks/*.json)."""
1643
+ return json.dumps(render_hooks('grok'), indent=2, ensure_ascii=False) + '\n'
1644
+
1645
+
1646
+ def _deep(doc, *keys):
1647
+ for key in keys:
1648
+ doc = doc.get(key) if isinstance(doc, dict) else None
1649
+ return doc
1650
+
1651
+
1652
+ def grok_config(text):
1653
+ """$GROK_HOME/config.toml with the lanes `hooks` block (GROK_CONFIG_BODY: `[compat.claude] hooks = false, mcps =
1654
+ false` — Grok Build otherwise also runs Claude Code's hooks and MCP servers from ~/.claude*, which say `--harness
1655
+ claude` — and `[ui.notifications]`: a BEL on every listed event, focused or not) and the `trace` block (each
1656
+ GROK_TRACE_MODELS `[model."<id>"] env_http_headers`, except a model the file configures outside the block, which
1657
+ is the person's: named instead). A `[compat.claude]` or `[ui.notifications]` of the person's refuses (TOML defines
1658
+ a table once): set those keys there by hand."""
1659
+ core, what = lane_core(), '~/.grok-build/config.toml'
1660
+ try:
1661
+ before = core._toml_loads(text or '', what)
1662
+ rest_text = core.without_block(core.without_block(text, 'hooks'), 'trace')
1663
+ rest = core._toml_loads(rest_text, what)
1664
+ except core.LaneError as error:
1665
+ raise _lane_error(f'{what}: {error}') from None
1666
+ if rest is not None:
1667
+ mine = [name for name, keys in (('[compat.claude]', ('compat', 'claude')),
1668
+ ('[ui.notifications]', ('ui', 'notifications')))
1669
+ if _deep(rest, *keys) is not None]
1670
+ free = [model for model in GROK_TRACE_MODELS if _deep(rest, 'model', model) is None]
1671
+ else: # no tomllib: what a textual rule can see
1672
+ mine = [name for name, word in (('[compat.claude]', 'compat'), ('[ui.notifications]', 'notifications'))
1673
+ if re.search(rf'(?m)^[ \t]*[^#\n]*\b{word}\b', rest_text)]
1674
+ free = [model for model in GROK_TRACE_MODELS if model not in rest_text]
1675
+ if mine:
1676
+ raise L.LanesError(f'{what} already has {" and ".join(mine)} of its own — left alone; set `[compat.claude] '
1677
+ 'hooks = false, mcps = false` and `[ui.notifications] condition = "always", method = "bel"` '
1678
+ 'there by hand')
1679
+ for model in GROK_TRACE_MODELS:
1680
+ if model not in free:
1681
+ print(f'! {what}: [model."{model}"] is configured outside the lanes block — add env_http_headers = '
1682
+ '{ "x-throttle-trace" = "LANES_TRACE" } to it by hand for the lanes trace')
1683
+ trace = ''.join(f'[model."{model}"]\nenv_http_headers = {{ "x-throttle-trace" = "LANES_TRACE" }}\n'
1684
+ for model in free)
1685
+ tables = ('[compat.claude]', '[ui.notifications]', *(f'[model."{model}"]' for model in GROK_TRACE_MODELS))
1686
+
1687
+ def ours(table):
1688
+ return table.split('\n', 1)[0].strip() in tables
1689
+ try:
1690
+ after = core.toml_block(text, 'hooks', GROK_CONFIG_BODY, ours)
1691
+ if trace:
1692
+ after = core.toml_block(after, 'trace', trace, ours)
1693
+ else:
1694
+ after = core.without_block(after, 'trace')
1695
+ except core.LaneError as error:
1696
+ raise _lane_error(f'{what}: {error}') from None
1697
+ if before is not None:
1698
+ expected = json.loads(json.dumps(rest))
1699
+ for key, value in GROK_CONFIG_VALUES.items():
1700
+ expected[key] = dict(expected.get(key) or {}, **value)
1701
+ for model in free:
1702
+ expected['model'] = dict(expected.get('model') or {}, **{model: {'env_http_headers': dict(GROK_TRACE_HEADERS)}})
1703
+ try:
1704
+ ok = core._toml_loads(after, what) == expected
1705
+ except core.LaneError:
1706
+ ok = False
1707
+ if not ok:
1708
+ raise L.LanesError(f'{what}: the lanes blocks could not be written without changing anything else — set '
1709
+ 'the keys by hand (RUNBOOK "Agent hooks")')
1710
+ return after
1711
+
1712
+
1713
+ def grok_cli_settings(text):
1714
+ """grok-cli's ~/.grok/user-settings.json with the lane-signal groups merged into its top-level `hooks` (Claude
1715
+ Code's shape; grok-cli matches a `matcher` as an exact string and runs every command through `sh -c` with its own
1716
+ environment, MEASURED in the 1.1.7 binary's source, 2026-10-15) — that one member edited, every other byte kept (the
1717
+ file holds grok-cli's apiKey); the result must parse back to the input with that member."""
1718
+ core, what = lane_core(), '~/.grok/user-settings.json'
1719
+ ours = render_hooks('grok-cli')
1720
+ if text is None or not text.strip():
1721
+ return json.dumps(ours, indent=2, ensure_ascii=False) + '\n'
1722
+ try:
1723
+ doc = core.strict_json(text, what)
1724
+ except core.LaneError as error:
1725
+ raise _lane_error(error) from None
1726
+ if not isinstance(doc, dict):
1727
+ raise L.LanesError(f'{what}: not a JSON object — refusing to rewrite it')
1728
+ merged = merge_hooks(doc, ours)['hooks']
1729
+ if merged == doc.get('hooks'):
1730
+ return text
1731
+ after = core.set_member(text, [], 'hooks', merged)
1732
+ if core.strict_json(after, what) != dict(doc, hooks=merged):
1733
+ raise L.LanesError(f'{what}: the hooks could not be merged without changing anything else — add them by hand')
1734
+ return after
1735
+
1736
+
1737
+ HOOK_EDITORS = {'kimi': (('.kimi-code/config.toml', kimi_config),),
1738
+ 'grok': (('.grok-build/hooks/lanes.json', grok_hooks_file), ('.grok-build/config.toml', grok_config)),
1739
+ 'grok-cli': (('.grok/user-settings.json', grok_cli_settings),)}
1740
+
1741
+
1742
+ def install_edits(home, edits, write):
1743
+ """Show each file's change (a diff, or `no change`) and, with `write`, apply them through lane_core.edit_file ->
1744
+ the changed paths. A refusal of any file (LanesError) is raised before anything is written."""
1745
+ core, planned = lane_core(), []
1746
+ for rel, transform in edits:
1747
+ path = home / rel
1748
+ try:
1749
+ _ident, before = core._snapshot(path, home)
1750
+ except core.LaneError as error:
1751
+ raise _lane_error(error) from None
1752
+ after = transform(before)
1753
+ if show_change(path, before or '', after):
1754
+ planned.append((path, transform))
1755
+ if not write:
1756
+ return []
1757
+ changed = []
1758
+ for path, transform in planned:
1759
+ try:
1760
+ _before, _after, saved = core.edit_file(path, transform, new_mode=0o600, backup=True, root=home)
1761
+ except core.LaneError as error:
1762
+ raise _lane_error(error) from None
1763
+ if saved:
1764
+ print(f'backup {saved}')
1765
+ print(f'wrote {path}')
1766
+ changed.append(path)
1767
+ return changed
1768
+
1769
+
1390
1770
  def write_with_backup(path, before, after):
1391
1771
  """Atomic replace, the previous bytes kept beside it first. `before` is what the diff was computed from."""
1392
1772
  if path.exists():
@@ -1402,6 +1782,9 @@ def write_with_backup(path, before, after):
1402
1782
  def cmd_hooks(args):
1403
1783
  """`lanes hooks install`: merge the lane-signal hook definitions into ONE container home's agent config. Dry run
1404
1784
  (the printed diff) unless --write; never touches Codex's hook trust (`[hooks.state]` is Codex's own decision).
1785
+ W12: kimi (a block in ~/.kimi-code/config.toml), grok (Grok Build: $GROK_HOME/hooks/lanes.json and blocks in
1786
+ $GROK_HOME/config.toml, GROK_HOME = ~/.grok-build) and grok-cli (`hooks` in ~/.grok/user-settings.json), through
1787
+ HOOK_EDITORS (`.bak-<UTC>` backups, no symbolic link followed).
1405
1788
  A --container target on a uid split (`uid_split`: this account or the home volume's owner is not the container
1406
1789
  uid) is refused with --write — the agent could not read the 0600 file this account would create — and warned in a
1407
1790
  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 +1816,18 @@ def cmd_hooks(args):
1433
1816
  if split and opts.get('--write'):
1434
1817
  raise L.LanesError(f"{split}: the agent (uid {L.CONTAINER_UID}) could not read what this account would write "
1435
1818
  f"into {home} — nothing written; run it from a host account with uid {L.CONTAINER_UID}")
1819
+ if agent in HOOK_EDITORS:
1820
+ install_edits(home, HOOK_EDITORS[agent], bool(opts.get('--write')))
1821
+ if not opts.get('--write'):
1822
+ if split:
1823
+ print(f'⚠ {split}: --write refuses here (run it from a host account with uid {L.CONTAINER_UID})')
1824
+ print('dry run: nothing written (add --write to apply)')
1825
+ return 0
1826
+ for rel in hook_files(agent): # DEF-1998: the host never reads or writes through a link the container planted
1827
+ try:
1828
+ lane_core().no_links(home, home / rel)
1829
+ except lane_core().LaneError as error:
1830
+ raise _lane_error(f'{error}: nothing written') from None
1436
1831
  target = home / HOOK_TARGETS[agent][1]
1437
1832
  before = target.read_text() if target.exists() else ''
1438
1833
  try:
@@ -2351,7 +2746,9 @@ def cmd_workspaces(args):
2351
2746
  # ------------------------------------------------------------------- onboard
2352
2747
 
2353
2748
  ONBOARD_USAGE = ('usage: lanes onboard <login> --container <name> [--workspace <id> --repo <path> [--title T]] '
2354
- '[--write] [--no-mcp]')
2749
+ '[--write] [--no-mcp] [--step kimi-notify|grok-build]...')
2750
+ ONBOARD_OPTIONAL = ('kimi-notify', 'grok-build') # steps that run only when named with --step (W12)
2751
+ HARNESS_NAMES = {'kimi': 'Kimi Code', 'grok': 'Grok Build', 'grok-cli': 'grok-cli'}
2355
2752
  # The receipt's status vocabulary. A dry run reports planned, unchanged, skipped, manual or failed, and `ok` for the
2356
2753
  # read-only `inspect` of the container; `manual` names a step a person still has to take (printed with the exact command);
2357
2754
  # `warn` is a step that ran and reported a problem that does not stop onboarding (the doctor's verdict, a board with
@@ -2372,7 +2769,7 @@ THROTTLE_PROBE = ('if ! command -v throttle >/dev/null; then echo absent; '
2372
2769
  THROTTLE_SETUP = 'throttle setup --install-clients'
2373
2770
  THROTTLE_PROBE_TIMEOUT = 20
2374
2771
  THROTTLE_TIMEOUT = 120
2375
- SITE_LINE = re.compile(r'^(LANES_[A-Z_]+)=([A-Za-z0-9_.:@/-]*)$') # also what `lanes client-config` hands out
2772
+ SITE_LINE = L.SITE_LINE # also what `lanes client-config` hands out
2376
2773
 
2377
2774
 
2378
2775
  def onboard_step(name, status, detail=''):
@@ -2394,13 +2791,10 @@ def uid_split(home):
2394
2791
  return None
2395
2792
 
2396
2793
 
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)
2794
+ def site_profile(home=None):
2795
+ """This server's site facts: the LANES_* KEY=VALUE lines of ~/.config/lanes/site.conf (read, never executed;
2796
+ L.site_conf, `home` for a test's)."""
2797
+ return L.site_conf(home)
2404
2798
 
2405
2799
 
2406
2800
  def provision_throttle(container, site, write=True):
@@ -2465,13 +2859,134 @@ def provision_laptop(steps, site):
2465
2859
  return steps
2466
2860
 
2467
2861
 
2862
+ # ------------------------------------------------------------------- W12 optional onboard steps
2863
+
2864
+ KIMI_NOTIFY_LINE = 'notification_condition = "always"'
2865
+ KIMI_NOTIFY_TABLE = re.compile(r'^[ \t]*\[[ \t]*notifications[ \t]*\][ \t]*(?:#.*)?$')
2866
+ KIMI_NOTIFY_KEY = re.compile(r'^([ \t]*)notification_condition[ \t]*=[ \t]*("[^"\\]*"|\'[^\']*\')[ \t]*(#.*)?$')
2867
+
2868
+
2869
+ def kimi_notify(text):
2870
+ """~/.kimi-code/tui.toml with `[notifications] notification_condition = "always"` (operator-approved per container,
2871
+ `lanes onboard --step kimi-notify`): Kimi Code then rings the terminal bell on every turn end, focused or not, and
2872
+ tmux sets the pane's window_bell_flag. Kimi Code's own default tui.toml already HAS a `[notifications]` table
2873
+ (`enabled = true`, `notification_condition = "unfocused"`; MEASURED in the 2.1.1 binary, 2026-10-15), and TOML defines
2874
+ a table once, so that table's key is edited in place (its old value kept in a comment); only a file without the
2875
+ table gets the marker block `notify`."""
2876
+ core, what = lane_core(), '~/.kimi-code/tui.toml'
2877
+ body = '[notifications]\n' + KIMI_NOTIFY_LINE + '\n'
2878
+ if text is None or not text.strip():
2879
+ return core.toml_block(None, 'notify', body)
2880
+ try:
2881
+ before = core._toml_loads(text, what)
2882
+ rest = core.without_block(text, 'notify')
2883
+ rows = text.splitlines(True)
2884
+ _rows, first, last = core._block_rows(text, 'notify')
2885
+ except core.LaneError as error:
2886
+ raise _lane_error(f'{what}: {error}') from None
2887
+ headers = [i for i, row in enumerate(rows) if KIMI_NOTIFY_TABLE.match(row.rstrip('\r\n'))
2888
+ and (first is None or not first < i < last)]
2889
+ if first is not None and not headers:
2890
+ after = core.toml_block(text, 'notify', body)
2891
+ elif len(headers) == 1:
2892
+ start = headers[0] + 1
2893
+ end = next((i for i in range(start, len(rows)) if rows[i].lstrip().startswith('[')), len(rows))
2894
+ keys = [i for i in range(start, end) if KIMI_NOTIFY_KEY.match(rows[i].rstrip('\r\n'))]
2895
+ if len(keys) > 1 or any(re.match(r'[ \t]*notification_condition[ \t]*=', rows[i]) and i not in keys
2896
+ for i in range(start, end)):
2897
+ raise L.LanesError(f'{what}: [notifications] has a notification_condition this edit cannot read — set it '
2898
+ 'to "always" by hand')
2899
+ if keys:
2900
+ row = rows[keys[0]]
2901
+ found = KIMI_NOTIFY_KEY.match(row.rstrip('\r\n'))
2902
+ if found.group(2).strip('"\'') == 'always':
2903
+ return text
2904
+ ending = row[len(row.rstrip('\r\n')):] or '\n'
2905
+ rows[keys[0]] = (f'{found.group(1)}{KIMI_NOTIFY_LINE} # lanes onboard --step kimi-notify (was '
2906
+ f'{found.group(2)})' + ending)
2907
+ else:
2908
+ rows.insert(start, KIMI_NOTIFY_LINE + ' # lanes onboard --step kimi-notify\n')
2909
+ after = ''.join(rows)
2910
+ elif headers:
2911
+ raise L.LanesError(f'{what}: [notifications] appears {len(headers)} times — fix the file by hand')
2912
+ elif re.search(r'(?m)^[ \t]*notifications[ \t]*[=.]', rest):
2913
+ raise L.LanesError(f'{what}: notifications is not written as a [notifications] table — set '
2914
+ 'notification_condition = "always" by hand')
2915
+ else:
2916
+ after = core.toml_block(text, 'notify', body)
2917
+ if before is not None:
2918
+ expected = json.loads(json.dumps(before))
2919
+ expected['notifications'] = dict(expected.get('notifications') or {}, notification_condition='always')
2920
+ try:
2921
+ ok = core._toml_loads(after, what) == expected
2922
+ except core.LaneError:
2923
+ ok = False
2924
+ if not ok:
2925
+ raise L.LanesError(f'{what}: notification_condition could not be set without changing anything else — '
2926
+ 'set it to "always" by hand')
2927
+ return after
2928
+
2929
+
2930
+ # `lanes onboard --step grok-build`: Grok Build installed under GROK_HOME=~/.grok-build inside the container, as uid
2931
+ # 1000, by the measured recipe (evidence 2026-10-15 "Container install recipe", derived from x.ai's install.sh): the
2932
+ # stable channel's version, the zstd artifact (else the plain one), the binary at downloads/grok-linux-x86_64 and the
2933
+ # relative link bin/grok (so the pane's command and comm stay `grok`), `[cli] installer = "internal"`. It never writes
2934
+ # ~/.grok (grok-cli's: Grok Build's own installer would replace grok-cli's bin/grok there), edits no shell rc file and
2935
+ # links nothing into ~/.local/bin. The channel publishes no checksum (no GitHub release assets; MEASURED 2026-10-15), so
2936
+ # the download cannot be verified: the step prints and records its size and sha256 as UNVERIFIED, and it runs only
2937
+ # when the operator names it (`--step grok-build`).
2938
+ GROK_BUILD_TIMEOUT = 900
2939
+ GROK_BUILD_RECIPE = (
2940
+ 'set -eu; G="$HOME/.grok-build"; '
2941
+ '[ "$(uname -m)" = x86_64 ] || { echo "grok-build: only linux x86_64 builds exist, this is $(uname -m)" >&2; exit 3; }; '
2942
+ 'V=$(curl -fsSL https://x.ai/cli/stable); '
2943
+ 'case "$V" in ""|*[!0-9.]*) echo "grok-build: the stable channel answered no version" >&2; exit 3;; esac; '
2944
+ 'mkdir -p "$G/downloads" "$G/bin"; P="$G/downloads/.grok-linux-x86_64.part"; rm -f "$P" "$P.zst"; '
2945
+ 'if command -v zstd >/dev/null 2>&1 && curl -fsSL -o "$P.zst" "https://x.ai/cli/grok-$V-linux-x86_64.zst"; '
2946
+ 'then zstd -q -dc "$P.zst" > "$P"; rm -f "$P.zst"; '
2947
+ 'else rm -f "$P.zst"; curl -fsSL -o "$P" "https://x.ai/cli/grok-$V-linux-x86_64"; fi; '
2948
+ 'chmod 755 "$P"; mv -f "$P" "$G/downloads/grok-linux-x86_64"; '
2949
+ 'ln -sfn ../downloads/grok-linux-x86_64 "$G/bin/grok"; '
2950
+ '[ -e "$G/config.toml" ] || printf \'[cli]\\ninstaller = "internal"\\n\' > "$G/config.toml"; '
2951
+ 'R=$(GROK_HOME="$G" "$G/bin/grok" --version 2>/dev/null | head -n 1); '
2952
+ 'echo "grok-build version=$V bytes=$(wc -c < "$G/downloads/grok-linux-x86_64") '
2953
+ 'sha256=$(sha256sum "$G/downloads/grok-linux-x86_64" | cut -d" " -f1) runs=$R"')
2954
+ GROK_BUILD_LINE = re.compile(r'^grok-build version=([0-9.]+) bytes=(\d+) sha256=([0-9a-f]{64}) runs=(.*)$')
2955
+
2956
+
2957
+ def provision_grok_build(container, write):
2958
+ """The `grok-build` onboard step -> (status, detail). A dry run prints the command; --write runs it (bounded,
2959
+ GROK_BUILD_TIMEOUT) and reports the installed version with the download's size and sha256, marked unverified."""
2960
+ uid = str(L.CONTAINER_UID)
2961
+ argv = ['docker', 'exec', '-u', uid, container, 'sh', '-lc', GROK_BUILD_RECIPE]
2962
+ shown = f"docker exec -u {uid} {container} sh -lc '<GROK_BUILD_RECIPE>' (GROK_HOME=~/.grok-build)"
2963
+ if not write:
2964
+ print(f'would run: {shown}')
2965
+ return 'planned', 'Grok Build into ~/.grok-build (stable channel; the download is unverified: no checksum)'
2966
+ rc, out, err = L.run_bounded(argv, GROK_BUILD_TIMEOUT)
2967
+ found = next((GROK_BUILD_LINE.match(line.strip()) for line in reversed(out.splitlines())
2968
+ if GROK_BUILD_LINE.match(line.strip())), None)
2969
+ if rc != 0 or not found:
2970
+ last = next((line.strip() for line in reversed((err or '').splitlines()) if line.strip()), '')
2971
+ reason = f'no answer within {GROK_BUILD_TIMEOUT} s' if rc is None else f'exit {rc}'
2972
+ print(f'✗ grok-build: {reason}' + (f' ({last[:160]})' if last else ''))
2973
+ return 'failed', f'{reason}: Grok Build not installed in {container}' + (f' ({last[:160]})' if last else '')
2974
+ version, size, digest, runs = found.groups()
2975
+ detail = (f'Grok Build {version} in ~/.grok-build (bin/grok -> downloads/grok-linux-x86_64; {runs or "no --version"}'
2976
+ f') — download UNVERIFIED (the stable channel publishes no checksum): {size} bytes, sha256 {digest}')
2977
+ print(f'⚠ grok-build: {detail}')
2978
+ return 'warn', detail
2979
+
2980
+
2468
2981
  def onboard_args(args):
2469
2982
  if not args or args[0].startswith('-'):
2470
2983
  raise L.LanesError(ONBOARD_USAGE)
2471
2984
  opts, rest = {}, list(args[1:])
2472
2985
  while rest:
2473
2986
  word = rest.pop(0)
2474
- if word in ('--container', '--workspace', '--repo', '--title') and rest and word not in opts:
2987
+ if word == '--step' and rest and rest[0] in ONBOARD_OPTIONAL:
2988
+ opts.setdefault('--step', []).append(rest.pop(0))
2989
+ elif word in ('--container', '--workspace', '--repo', '--title') and rest and word not in opts:
2475
2990
  opts[word] = rest.pop(0)
2476
2991
  elif word in ('--write', '--no-mcp'):
2477
2992
  opts[word] = True
@@ -2613,10 +3128,20 @@ def cmd_onboard(args):
2613
3128
  if lane_core().read_regular(path, lane_core().EDIT_MAX) != mcp_before.get(path)]
2614
3129
  return ('changed', ', '.join(changed)) if changed else ('unchanged', files)
2615
3130
 
3131
+ def installed(agent):
3132
+ """None when `agent` is installed in the container (its home is a real directory), else why not."""
3133
+ rel = HOOK_HOMES.get(agent)
3134
+ if rel is None or (home() / rel).is_dir() and not (home() / rel).is_symlink():
3135
+ return None
3136
+ return f'no ~/{rel} in the container: {HARNESS_NAMES[agent]} is not installed there'
3137
+
2616
3138
  def hooks_step(agent):
2617
3139
  def step():
2618
3140
  target = home()
2619
- files = [target / HOOK_TARGETS[agent][1]] + ([target / '.codex/config.toml'] if agent == 'codex' else [])
3141
+ missing = installed(agent)
3142
+ if missing:
3143
+ return 'skipped', missing + (' (--step grok-build installs it)' if agent == 'grok' else '')
3144
+ files = [target / rel for rel in hook_files(agent)]
2620
3145
  before = [path.read_bytes() if path.exists() else None for path in files]
2621
3146
  cmd_hooks(['install', '--agent', agent, '--container', container, '--write' if write else '--dry-run'])
2622
3147
  if not write:
@@ -2626,6 +3151,24 @@ def cmd_onboard(args):
2626
3151
  return ('changed', ', '.join(changed)) if changed else ('unchanged', str(files[0]))
2627
3152
  return step
2628
3153
 
3154
+ def kimi_notify_step():
3155
+ if 'kimi-notify' not in opts.get('--step', []):
3156
+ return 'skipped', 'optional: --step kimi-notify (Kimi Code rings the bell on every turn end, focused or not)'
3157
+ missing = installed('kimi')
3158
+ if missing:
3159
+ return 'skipped', missing
3160
+ path = home() / '.kimi-code/tui.toml'
3161
+ before = path.read_bytes() if path.exists() else None
3162
+ install_edits(home(), (('.kimi-code/tui.toml', kimi_notify),), write)
3163
+ if not write:
3164
+ return 'planned', str(path)
3165
+ return ('changed' if (path.read_bytes() if path.exists() else None) != before else 'unchanged'), str(path)
3166
+
3167
+ def grok_build_step():
3168
+ if 'grok-build' not in opts.get('--step', []):
3169
+ return 'skipped', 'optional: --step grok-build (installs Grok Build into ~/.grok-build)'
3170
+ return provision_grok_build(container, write)
3171
+
2629
3172
  def sync_step():
2630
3173
  if not write:
2631
3174
  print('would run: lanes sync')
@@ -2679,9 +3222,13 @@ def cmd_onboard(args):
2679
3222
 
2680
3223
  print(f"lanes onboard {login} -> container {container}" + (f', board {board}' if board else '')
2681
3224
  + ('' if write else ' (dry run)'))
2682
- plan = [('inspect', inspect_step, True), ('board', board_step, True), ('contract', contract_step, True),
3225
+ # grok-build (opt-in) runs before the contract, so install-container's MCP entries and hooks-grok find its home in
3226
+ # the same run.
3227
+ plan = [('inspect', inspect_step, True), ('board', board_step, True), ('grok-build', grok_build_step, False),
3228
+ ('contract', contract_step, True),
2683
3229
  ('lane-mcp', mcp_step, False), ('hooks-claude', hooks_step('claude'), False),
2684
- ('hooks-codex', hooks_step('codex'), False),
3230
+ ('hooks-codex', hooks_step('codex'), False), ('hooks-kimi', hooks_step('kimi'), False),
3231
+ ('kimi-notify', kimi_notify_step, False), ('hooks-grok', hooks_step('grok'), False), ('hooks-grok-cli', hooks_step('grok-cli'), False),
2685
3232
  ('throttle', lambda: provision_throttle(container, site, write), False),
2686
3233
  ('sync', sync_step, False), ('cursor-sync', cursor_step, False), ('resync', resync_step, False),
2687
3234
  ('doctor', doctor_step, False)]
@@ -2697,7 +3244,8 @@ def cmd_onboard(args):
2697
3244
  for key, value in (('login', login), ('container', container), ('workspace', board or '-')):
2698
3245
  print(f'{key + ":":<14}{value}')
2699
3246
  for step in steps:
2700
- print(f"{step['name'] + ':':<14}{step['status']:<10} {step['detail']}")
3247
+ print(f"{step['name'] + ':':<14}" + ('' if len(step['name']) < 13 else ' ') # hooks-grok-cli: 15 wide
3248
+ + f"{step['status']:<10} {step['detail']}")
2701
3249
  if write:
2702
3250
  print(f"{'receipt:':<14}{onboard_receipt(login, container, board, steps)}")
2703
3251
  else:
@@ -4240,6 +4788,213 @@ def docs_doctor(home=None, probe=None):
4240
4788
  docs_mermaid_line(home), docs_age_line()]
4241
4789
 
4242
4790
 
4791
+ # ------------------------------------------------------------------ doctor: the fleet and the Throttle pulse (W11)
4792
+ # Four lines (W12 added the pools line) from what statusd published (spec
4793
+ # docs/superpowers/specs/2026-10-11-lanes-fleet-dashboard-design.md §2, §4): status.json's `throttle` (the
4794
+ # container-side pulse's ~/.lz/state/throttle.json as statusd copied it, with `stale`) and `fleet` (host and top
4795
+ # containers, OTel names as keys), and site.conf's LANES_FLEET_DASHBOARD_URL. doctor reads files only: it never asks the
4796
+ # daemon, never starts the pulse and never runs docker. A missing pulse, a daemon that does not answer and a stale fleet
4797
+ # are warnings (⚠), never a ✗: Lanes runs without Throttle and without them. The pulse rewrites its file at least every
4798
+ # 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`:
4799
+ # statusd calls it stale past 90 s (statusd.PULSE_STALE_S, DEF-1941), and doctor's own clock uses the same bound, so the
4800
+ # two never disagree about a healthy pulse.
4801
+ PULSE_FRESH_S = 90
4802
+ # The pulse's pools in its order (statusd.PULSE_POOLS, the throttle-pulse POOLS; W12 added kimi), named by the
4803
+ # `throttle pools:` line. A pool the pulse did not report says `?`: a pulse from before 1.5.0, or a daemon from before
4804
+ # its Kimi pool (`/pools/kimi/ready` answers 404 there).
4805
+ PULSE_POOLS = ('claude', 'codex', 'kimi')
4806
+ FLEET_FRESH_S = 60 # statusd republishes `fleet` every FLEET_INTERVAL (10 s) unless the host is critical
4807
+ THROTTLE_MONITOR = 'tools--throttle-monitor'
4808
+ FLEET_DASHBOARD_KEY = 'LANES_FLEET_DASHBOARD_URL'
4809
+ # The dashboard link (spec §1, §3): https only, a DNS host name, an optional port and a plain path — what an OSC 8 link
4810
+ # and a browser may be handed without escaping. No user part (no credential in a URL), no query or fragment (site.conf
4811
+ # lines carry neither: SITE_LINE refuses `?`, `#`, `=` and `&`). Accepted when an operator writes it into the server's
4812
+ # ~/.config/lanes/site.conf; no Lanes code path ever writes it (test_server_cli.FleetDashboardUrl pins both).
4813
+ FLEET_DASHBOARD_URL = re.compile(r'https://(?=[^/:]{1,253}(?::|/|\Z))[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
4814
+ r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*'
4815
+ r'(?::(?P<port>[0-9]{1,5}))?(?:/[A-Za-z0-9._~/-]*)?\Z', re.ASCII)
4816
+
4817
+
4818
+ def fleet_dashboard_url(home=None):
4819
+ """site.conf's LANES_FLEET_DASHBOARD_URL -> (url, problem). (None, None): unset — the state until an authenticated
4820
+ Grafana deployment exists; (url, None): accepted; (None, why): written but refused (not https, a user part, a port
4821
+ out of range, a character outside the plain set), so nothing links to it. The last line naming the key wins, as in
4822
+ site_profile; a line SITE_LINE does not admit (a `?` in it, a space) is judged here too rather than read as unset."""
4823
+ try:
4824
+ text = ((Path(home) if home is not None else Path.home()) / '.config/lanes/site.conf').read_text()
4825
+ except (OSError, UnicodeDecodeError):
4826
+ return None, None
4827
+ values = [line.split('=', 1)[1].strip() for line in text.splitlines()
4828
+ if line.startswith(FLEET_DASHBOARD_KEY + '=')]
4829
+ if not values or not values[-1]:
4830
+ return None, None
4831
+ value = values[-1]
4832
+ if not value.startswith('https://'):
4833
+ return None, 'not an https URL'
4834
+ match = FLEET_DASHBOARD_URL.match(value)
4835
+ if not match:
4836
+ return None, 'not a plain https://host[:port][/path] URL (no user part, query, fragment or space)'
4837
+ if match.group('port') is not None and not 0 < int(match.group('port')) < 65536:
4838
+ return None, 'port out of range'
4839
+ return value, None
4840
+
4841
+
4842
+ def throttle_container(registry=None, home=None):
4843
+ """(container, where it was named) of the Throttle daemon's container (and so the pulse's, spec §2.1), or (None,
4844
+ None): site.conf's LANES_THROTTLE_CONTAINER (site_profile, `home` for a test's), else the container of the
4845
+ registry's Throttle Monitor tab (`tools--throttle-monitor`; `registry` None reads <state>/registry.json). A name
4846
+ that is not an identifier names nothing. install-container (which starts the pulse), doctor and the update's
4847
+ re-ship (update.daemon_container) all read the setting from site.conf, so they name the same container."""
4848
+ named = site_profile(home).get('LANES_THROTTLE_CONTAINER')
4849
+ if named and L.IDENT.fullmatch(named):
4850
+ return named, 'site.conf LANES_THROTTLE_CONTAINER'
4851
+ if registry is None:
4852
+ registry = L.read_json(L.STATE / 'registry.json', {})
4853
+ for tool in (registry.get('tools') if isinstance(registry, dict) else None) or ():
4854
+ if isinstance(tool, dict) and tool.get('id') == THROTTLE_MONITOR and isinstance(tool.get('container'), str) \
4855
+ and L.IDENT.fullmatch(tool['container']):
4856
+ return tool['container'], f"the registry's {THROTTLE_MONITOR}"
4857
+ return None, None
4858
+
4859
+
4860
+ def age_shown(seconds):
4861
+ seconds = max(0, int(seconds))
4862
+ if seconds < 120:
4863
+ return f'{seconds} s'
4864
+ return f'{seconds // 60} min' if seconds < 7200 else f'{seconds // 3600} h'
4865
+
4866
+
4867
+ def number(value):
4868
+ """A finite JSON number, else None (a bool, a string, NaN or an infinity a hand-edited file could hold)."""
4869
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
4870
+
4871
+
4872
+ def pulse_line(throttle, container, now):
4873
+ """The pulse: its age by `at`, the container it runs in, whether the daemon answered and whether it holds the
4874
+ opt-in bearer (money and burn lines need it; `lanes throttle adopt-token <c>` is the operator's step)."""
4875
+ where = (f'container {container}' if container else
4876
+ 'no daemon container: site.conf LANES_THROTTLE_CONTAINER unset and no Throttle tab')
4877
+ adopt = f'lanes throttle adopt-token {container}' if container else 'lanes throttle adopt-token <container>'
4878
+ at = number((throttle or {}).get('at')) if isinstance(throttle, dict) else None
4879
+ if at is None:
4880
+ start = (f'lanes install-container {container} starts it in the daemon\'s container' if container else
4881
+ 'name the daemon\'s container with LANES_THROTTLE_CONTAINER in ~/.config/lanes/site.conf, then lanes '
4882
+ 'install-container <container>')
4883
+ return '⚠', f'throttle pulse: none in status.json ({where}) — {start}'
4884
+ age = now - at
4885
+ up = throttle.get('up') is True
4886
+ error = throttle.get('error')
4887
+ daemon = 'daemon up' if up else 'daemon down' + (f' ({plain(error)[:80]})' if isinstance(error, str) and error else '')
4888
+ tokened = throttle.get('tokened') is True
4889
+ text = (f'throttle pulse: {age_shown(age)} old ({where}), {daemon}, tokened: {"yes" if tokened else "no"}'
4890
+ + ('' if tokened else f' — {adopt}'))
4891
+ if throttle.get('stale') is True or age > PULSE_FRESH_S:
4892
+ rearm = f'lanes install-container {container}' if container else 'lanes install-container <container>'
4893
+ return '⚠', text + f'; stale: the pulse stopped (statusd re-arms it at most every 10 min, or {rearm})'
4894
+ return ('✓' if up else '⚠'), text
4895
+
4896
+
4897
+ def pools_line(throttle):
4898
+ """The pools the pulse reports, usable of configured: `throttle pools: claude 12/19 · codex 3/5 · kimi 5/8 usable`.
4899
+ A pool with nothing configured is `0/0` (a daemon with no Kimi account), one the pulse did not report `?`. A `⚠`
4900
+ when a configured pool has no usable account (the sidebar's attention tone); otherwise information. The pulse
4901
+ line above says how old these figures are."""
4902
+ pools = throttle.get('pools') if isinstance(throttle, dict) else None
4903
+ if not isinstance(pools, dict) or number(throttle.get('at')) is None:
4904
+ return '', 'throttle pools: none in status.json'
4905
+
4906
+ def count(value):
4907
+ return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
4908
+ parts, empty = [], []
4909
+ for pool in PULSE_POOLS:
4910
+ row = pools.get(pool) if isinstance(pools.get(pool), dict) else {}
4911
+ configured, usable = count(row.get('configured')), count(row.get('usable'))
4912
+ if configured is None:
4913
+ parts.append(f'{pool} ?')
4914
+ continue
4915
+ usable = min(usable or 0, configured)
4916
+ parts.append(f'{pool} {usable}/{configured}')
4917
+ if configured and not usable:
4918
+ empty.append(pool)
4919
+ text = 'throttle pools: ' + ' · '.join(parts) + ' usable'
4920
+ if empty:
4921
+ return '⚠', text + f" — no usable account in {', '.join(empty)}"
4922
+ return '', text
4923
+
4924
+
4925
+ def fleet_line(fleet, containers, now):
4926
+ """The host: cpu and memory utilization, the highest PSI avg10, and how many workspace containers run."""
4927
+ containers = containers if isinstance(containers, dict) else {}
4928
+ running = sum(1 for info in containers.values() if isinstance(info, dict) and info.get('running'))
4929
+ if not isinstance(fleet, dict) or number(fleet.get('at')) is None:
4930
+ return '⚠', (f'fleet: not in status.json (containers {running}) — a statusd from before 1.4.0 '
4931
+ '(systemctl --user restart lanes-statusd.service)')
4932
+
4933
+ def percent(value):
4934
+ value = number(value)
4935
+ return '?' if value is None else f'{round(value * 100)}%'
4936
+ pressure = fleet.get('system.pressure') if isinstance(fleet.get('system.pressure'), dict) else {}
4937
+ psi = [value for value in map(number, pressure.values()) if value is not None]
4938
+ text = (f"fleet: cpu {percent(fleet.get('system.cpu.utilization'))} · "
4939
+ f"mem {percent(fleet.get('system.memory.utilization'))} · "
4940
+ f"psi {f'{round(max(psi))}%' if psi else '?'} · containers {running}")
4941
+ age = now - fleet['at']
4942
+ if fleet.get('stale') is True or age > FLEET_FRESH_S:
4943
+ return '⚠', text + f' — stale ({age_shown(age)} old: statusd skips it while the host is critical)'
4944
+ return '', text
4945
+
4946
+
4947
+ def dashboard_line(home=None):
4948
+ url, problem = fleet_dashboard_url(home)
4949
+ if problem:
4950
+ return '⚠', f'fleet dashboard: {FLEET_DASHBOARD_KEY} ignored — {problem}; nothing links to it'
4951
+ if url is None:
4952
+ return '', f'fleet dashboard: {FLEET_DASHBOARD_KEY} unset'
4953
+ return '', f'fleet dashboard: {url}'
4954
+
4955
+
4956
+ def fleet_doctor(status, registry, home=None, now=None):
4957
+ """`lanes doctor`'s W11 lines -> [(mark, text)] (✓ | ⚠ | '' information; never ✗), in this order: the Throttle
4958
+ pulse, its pools (W12: claude, codex, kimi), the fleet, the dashboard link. Read-only: status.json (already
4959
+ read), the registry, site.conf."""
4960
+ now = time.time() if now is None else now
4961
+ status = status if isinstance(status, dict) else {}
4962
+ container, _named_by = throttle_container(registry, home)
4963
+ return [pulse_line(status.get('throttle'), container, now),
4964
+ pools_line(status.get('throttle')),
4965
+ fleet_line(status.get('fleet'), status.get('containers'), now),
4966
+ dashboard_line(home)]
4967
+
4968
+
4969
+ # ------------------------------------------------------------------ doctor: the harness mix (W12)
4970
+ # `agent.harness` is an observed fact since W12 (spec
4971
+ # docs/superpowers/specs/2026-10-15-lanes-harness-observers-design.md §2): statusd reads it from the pane's process
4972
+ # tree. doctor counts what status.json carries — every lane row with an `agent` object, by its harness — and names each
4973
+ # value of the spec's enum, `unknown` (and any value outside the enum, such as an empty harness from a statusd before
4974
+ # W12) only when some lane has it. A row without `agent` (no signal names an agent there) is not counted. Information
4975
+ # only, never a ✗.
4976
+ HARNESSES = ('claude', 'codex', 'kimi', 'grok', 'grok-cli', 'shell')
4977
+
4978
+
4979
+ def harness_line(status):
4980
+ """`lanes: claude 50 · codex 20 · kimi 3 · grok 0 · grok-cli 0 · shell 0` from status.json's lane rows. `shell`
4981
+ counts an agent{} that names it, which statusd's merge never writes (a pane whose harness ended is published under
4982
+ that harness's name with status `shell`, DM-LANES-STATUS): the slot reads 0 unless a foreign sidecar says so."""
4983
+ counts, unknown = dict.fromkeys(HARNESSES, 0), 0
4984
+ lanes = status.get('lanes') if isinstance(status, dict) else None
4985
+ for row in lanes.values() if isinstance(lanes, dict) else ():
4986
+ agent = row.get('agent') if isinstance(row, dict) else None
4987
+ if not isinstance(agent, dict):
4988
+ continue
4989
+ harness = agent.get('harness')
4990
+ if isinstance(harness, str) and harness in counts:
4991
+ counts[harness] += 1
4992
+ else:
4993
+ unknown += 1
4994
+ return '', ('lanes: ' + ' · '.join(f'{name} {n}' for name, n in counts.items())
4995
+ + (f' · unknown {unknown}' if unknown else ''))
4996
+
4997
+
4243
4998
  # ------------------------------------------------------------------ doctor: the release that runs (W9 V3, LNS-85)
4244
4999
  # `lanes version` says which tree each process last started from; doctor judges it, with the reference `lanes version`
4245
5000
  # uses (the tree the `current` link names; this tree when there is none). A run record outlives its process, so only a
@@ -4569,6 +5324,8 @@ def cmd_doctor(_args):
4569
5324
  # 'unknown' / stale = statusd could not OBSERVE the lane (its tmux server did not answer): not the same as gone.
4570
5325
  check(not states.get('missing') and not states.get('unknown') and not stale, 'all registry lanes observed and present',
4571
5326
  str(states) + (f'; {stale} shown with their last known state (tmux server did not answer)' if stale else ''))
5327
+ # The harness mix (W12): which agent each lane runs, as statusd observed it; information only.
5328
+ print(f' {harness_line(status)[1]}')
4572
5329
  # One bounded process-table sweep serves both checks; the cgroup of the few tmux servers is a plain file read.
4573
5330
  rc, table, _err = L.run_bounded(['ps', '-eo', 'pid=,comm=,args='], 10)
4574
5331
  coexist = (L.STATE / 'flags/coexist').exists()
@@ -4626,6 +5383,12 @@ def cmd_doctor(_args):
4626
5383
  print(f'{mark or " "} {text}')
4627
5384
  else:
4628
5385
  check(mark == '✓', text)
5386
+ # The fleet and the Throttle pulse (W11): what statusd published and site.conf's dashboard link; never a ✗.
5387
+ for mark, text in fleet_doctor(status, registry):
5388
+ if mark in ('⚠', ''):
5389
+ print(f'{mark or " "} {text}')
5390
+ else:
5391
+ check(mark == '✓', text)
4629
5392
  # The release that runs (W9 V3): `current`, the run records against the one process-table sweep above, the
4630
5393
  # device layouts, the units.
4631
5394
  for mark, text in release_doctor(table if rc == 0 else None):
@@ -5304,7 +6067,7 @@ COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_cli
5304
6067
  'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
5305
6068
  'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
5306
6069
  'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, 'unregister': cmd_unregister,
5307
- 'hooks': cmd_hooks, 'economy': cmd_economy,
6070
+ 'hooks': cmd_hooks, 'economy': cmd_economy, 'throttle': cmd_throttle,
5308
6071
  'preview': cmd_preview, 'edit': cmd_edit, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev,
5309
6072
  'sidebar-key': cmd_sidebar_key, 'studio-host': cmd_studio_host,
5310
6073
  'keys': cmd_keys, 'invite': cmd_invite, 'enrol': cmd_enrol, 'offboard': cmd_offboard,