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