@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.
- package/package.json +1 -1
- package/payload/lanes/README.md +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +797 -34
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +13 -7
- package/payload/lanes/container/lane-mcp +56 -17
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +346 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +182 -14
- package/payload/lanes/docs/DESIGN.md +7 -0
- package/payload/lanes/docs/INSTALL.md +23 -3
- package/payload/lanes/docs/RUNBOOK.md +402 -5
- package/payload/lanes/docs/USERGUIDE.md +222 -23
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/attach.py +181 -0
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/sidebar.py +522 -14
- package/payload/lanes/libexec/statusd.py +434 -28
- package/payload/lanes/libexec/tokens.py +12 -1
- package/payload/lanes/libexec/update.py +78 -5
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/manifest.json +4 -3
package/payload/lanes/bin/lanes
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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)
|
|
203
|
-
|
|
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
|
|
1104
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1386
|
-
|
|
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 =
|
|
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
|
-
|
|
2400
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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,
|