@vibelord/lanes 1.3.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/lanes/README.md +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +797 -34
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +13 -7
- package/payload/lanes/container/lane-mcp +56 -17
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +346 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +182 -14
- package/payload/lanes/docs/DESIGN.md +7 -0
- package/payload/lanes/docs/INSTALL.md +23 -3
- package/payload/lanes/docs/RUNBOOK.md +402 -5
- package/payload/lanes/docs/USERGUIDE.md +222 -23
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/attach.py +181 -0
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/sidebar.py +522 -14
- package/payload/lanes/libexec/statusd.py +434 -28
- package/payload/lanes/libexec/tokens.py +12 -1
- package/payload/lanes/libexec/update.py +78 -5
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/manifest.json +4 -3
|
@@ -24,7 +24,14 @@ sees, and `lane-mcp` may write nothing but protocol frames to its stdout.
|
|
|
24
24
|
`shown_key`, `lane_trace`, `canonical_recipe`, the id grammar (each pinned equal to its lanes_lib original by
|
|
25
25
|
test_lane_mcp_write.HostRules), and `declared_name`, which finds a registry lane's declared terminal in its file.
|
|
26
26
|
* the agents' MCP entries: `mcpServers.lanes` in ~/.claude.json and `[mcp_servers.lanes]` (command + env_vars) in
|
|
27
|
-
~/.codex/config.toml
|
|
27
|
+
~/.codex/config.toml — and (W12) `mcpServers.lanes` in ~/.kimi-code/mcp.json, a `# >>> lanes mcp >>>` block with
|
|
28
|
+
`[mcp_servers.lanes]` in Grok Build's ~/.grok-build/config.toml and the `lanes` item of grok-cli's `mcp.servers` in
|
|
29
|
+
~/.grok/user-settings.json, those three where the harness's home exists — planned as a diff, written with a
|
|
30
|
+
`.bak-<UTC>` copy, atomically and only when missing. TOML marker blocks (toml_block) are shared with the host's
|
|
31
|
+
`lanes hooks install`.
|
|
32
|
+
* the harness of a process (W12): process_harness / parent_harness over /proc (comm, the grok exe split, a native
|
|
33
|
+
Claude Code file), and whoami's fallback to the agent's own /proc environ (parent_environ) for a harness that strips
|
|
34
|
+
its MCP servers' environment (grok-cli).
|
|
28
35
|
* publish requests (plan W10 W4): `publish` writes ~/.lz/state/publish/<lane>/<id>.json for `lane publish` and
|
|
29
36
|
lane-mcp's `lanes_publish` (the file hashed once, the id in docs_store's grammar: `new_publish_id`), and
|
|
30
37
|
`latest_receipt` reads the `<id>.receipt.json` the host's lanes-docsd writes back (`lanes_status`'s `published`).
|
|
@@ -51,7 +58,9 @@ HOOK_MAX_AGE = 3600 # statusd.SIDECAR_MAX_AGE: an older last event is no a
|
|
|
51
58
|
STATUS_FRESH = 60 # status.json `at` is renewed at least every 15 s (statusd.HEARTBEAT); older = not live
|
|
52
59
|
READ_MAX = 16 << 20 # a projection bigger than this is not ours
|
|
53
60
|
TMUX_TIMEOUT = 5
|
|
54
|
-
IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE') # +
|
|
61
|
+
IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE', 'LANES_TRACE') # + our trace headers and GROK_HOME (identity_free)
|
|
62
|
+
TRACE_HEADERS = ('ANTHROPIC_CUSTOM_HEADERS', 'KIMI_CODE_CUSTOM_HEADERS') # ours: 'x-throttle-trace: lanes/<ws>/<id>'
|
|
63
|
+
GROK_HOME = CONTAINER_HOME + '/.grok-build' # Grok Build's home in a lane, never grok-cli's ~/.grok (DM-LANES-GROK-HOME)
|
|
55
64
|
|
|
56
65
|
|
|
57
66
|
class LaneError(Exception):
|
|
@@ -204,9 +213,11 @@ def identity_free(environ, drop=('TMUX',)):
|
|
|
204
213
|
a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
|
|
205
214
|
that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1559). Board sockets are shared by
|
|
206
215
|
many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
|
|
207
|
-
An operator's own ANTHROPIC_CUSTOM_HEADERS that is not a lane trace is theirs and stays
|
|
216
|
+
An operator's own ANTHROPIC_CUSTOM_HEADERS / KIMI_CODE_CUSTOM_HEADERS that is not a lane trace is theirs and stays,
|
|
217
|
+
and so is a GROK_HOME that is not the one the recipe stamps (W12)."""
|
|
208
218
|
return {k: v for k, v in environ.items() if k not in drop and k not in IDENTITY
|
|
209
|
-
and not (k
|
|
219
|
+
and not (k in TRACE_HEADERS and v.startswith('x-throttle-trace: lanes/'))
|
|
220
|
+
and not (k == 'GROK_HOME' and v == GROK_HOME)}
|
|
210
221
|
|
|
211
222
|
|
|
212
223
|
def tmux_env(environ=None):
|
|
@@ -298,7 +309,7 @@ def last_lines(text, lines):
|
|
|
298
309
|
def new_session_argv(socket, session, cwd, lane_id, ws, command):
|
|
299
310
|
"""The one tmux client lane-mcp runs WITHOUT -N (`lanes_new`): it creates the session, and starts the socket's
|
|
300
311
|
server when none runs. The lane's identity reaches the session only through these `-e` flags, the canonical
|
|
301
|
-
recipe's
|
|
312
|
+
recipe's seven (the client's own environment is creation_env(), an allow-list: whatever client starts a server
|
|
302
313
|
hands its environment to every later session there, DEF-1559, DEF-1802). `command` is one shell-command string,
|
|
303
314
|
run by the server's default shell like the recipe's command."""
|
|
304
315
|
trace = lane_trace(lane_id, ws)
|
|
@@ -306,7 +317,9 @@ def new_session_argv(socket, session, cwd, lane_id, ws, command):
|
|
|
306
317
|
raise LaneError(f'invalid lane trace: lanes/{ws}/{lane_id}', 'invalid_name')
|
|
307
318
|
return ['tmux', '-L', socket, 'new-session', '-d', '-s', session, '-x', '100', '-y', '30', '-c', cwd,
|
|
308
319
|
'-e', f'LANE_ID={lane_id}', '-e', f'LANE_WS={ws}',
|
|
309
|
-
'-e', f'ANTHROPIC_CUSTOM_HEADERS=x-throttle-trace: {trace}', '-e', f'THROTTLE_TRACE={trace}',
|
|
320
|
+
'-e', f'ANTHROPIC_CUSTOM_HEADERS=x-throttle-trace: {trace}', '-e', f'THROTTLE_TRACE={trace}',
|
|
321
|
+
'-e', f'KIMI_CODE_CUSTOM_HEADERS=x-throttle-trace: {trace}', '-e', f'LANES_TRACE={trace}',
|
|
322
|
+
'-e', f'GROK_HOME={GROK_HOME}', command]
|
|
310
323
|
|
|
311
324
|
|
|
312
325
|
CREATION_ENV = ('HOME', 'USER', 'LOGNAME', 'PATH', 'LANG', 'LANGUAGE', 'TZ', 'TMUX_TMPDIR') # and every LC_*
|
|
@@ -379,8 +392,9 @@ def lane_trace(lane_id, ws):
|
|
|
379
392
|
|
|
380
393
|
def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
|
|
381
394
|
"""lanes_lib.canonical_recipe: the create recipe that gives a new tmux lane its identity (LANE_ID, LANE_WS, the
|
|
382
|
-
lanes/<ws>/<id> trace as ANTHROPIC_CUSTOM_HEADERS
|
|
383
|
-
the session exists. LaneError where the host raises
|
|
395
|
+
lanes/<ws>/<id> trace as ANTHROPIC_CUSTOM_HEADERS, THROTTLE_TRACE, KIMI_CODE_CUSTOM_HEADERS and LANES_TRACE, and
|
|
396
|
+
GROK_HOME) through `-e` flags; `new -A` attaches when the session exists. LaneError where the host raises
|
|
397
|
+
LanesError."""
|
|
384
398
|
trace = lane_trace(lane_id, ws)
|
|
385
399
|
if trace is None:
|
|
386
400
|
raise LaneError(f'Invalid lane trace: lanes/{ws}/{lane_id} (allowed: A-Z a-z 0-9 . _ - /, at most 256)',
|
|
@@ -396,7 +410,8 @@ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
|
|
|
396
410
|
header = shlex.quote(f'x-throttle-trace: {trace}')
|
|
397
411
|
return (f'tmux -L {shlex.quote(socket)} new -ADs {shlex.quote(session)} -c {shlex.quote(folder)} '
|
|
398
412
|
f'-e LANE_ID={lane_id} -e LANE_WS={ws} -e ANTHROPIC_CUSTOM_HEADERS={header} '
|
|
399
|
-
f'-e THROTTLE_TRACE={trace} {
|
|
413
|
+
f'-e THROTTLE_TRACE={trace} -e KIMI_CODE_CUSTOM_HEADERS={header} -e LANES_TRACE={trace} '
|
|
414
|
+
f'-e GROK_HOME={GROK_HOME} {cmd}')
|
|
400
415
|
|
|
401
416
|
|
|
402
417
|
def lane_name_problem(name):
|
|
@@ -469,17 +484,101 @@ def lane_kind(entry):
|
|
|
469
484
|
return 'registered' if entry.get('registered_session') else 'declared'
|
|
470
485
|
|
|
471
486
|
|
|
472
|
-
|
|
487
|
+
# The harnesses a process tree can name (W12; lane-signal's HARNESS_COMMS and parent chain, pinned equal by
|
|
488
|
+
# test_lane_core). A `grok` is Grok Build when its executable is $GROK_HOME/downloads/grok-linux-x86_64, else grok-cli.
|
|
489
|
+
HARNESS_COMMS = {'claude': 'claude', 'codex': 'codex', 'kimi': 'kimi', 'kimi-code': 'kimi', 'grok': 'grok'}
|
|
490
|
+
HARNESSES = ('claude', 'codex', 'kimi', 'grok', 'grok-cli', 'unknown')
|
|
491
|
+
GROK_BUILD_EXE = '/downloads/grok-linux-x86_64'
|
|
492
|
+
CLAUDE_EXE = '/claude/versions/' # native Claude Code before 2.1.299 kept its file's name, the version, as comm (MEASURED)
|
|
493
|
+
PARENT_DEPTH = 8
|
|
494
|
+
ENVIRON_MAX = 1 << 20 # /proc/<pid>/environ read at most this far (the kernel caps an environment far below it)
|
|
495
|
+
WHOAMI_VARS = ('LANE_ID', 'LANE_WS', 'TMUX', 'TMUX_PANE') # what whoami reads (MCP_ENV_VARS passes the same)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _stat(proc, pid):
|
|
499
|
+
"""(comm, ppid) of /proc/<pid>/stat, or None."""
|
|
500
|
+
data = read_regular(Path(proc) / str(pid) / 'stat', 1 << 16)
|
|
501
|
+
if data is None:
|
|
502
|
+
return None
|
|
503
|
+
text = data.decode('utf-8', 'replace')
|
|
504
|
+
try:
|
|
505
|
+
return text[text.index('(') + 1:text.rindex(')')], int(text.rsplit(')', 1)[1].split()[1])
|
|
506
|
+
except (ValueError, IndexError):
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _exe(proc, pid):
|
|
511
|
+
try:
|
|
512
|
+
return os.readlink(str(Path(proc) / str(pid) / 'exe')).removesuffix(' (deleted)')
|
|
513
|
+
except OSError:
|
|
514
|
+
return ''
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def process_harness(proc, pid):
|
|
518
|
+
"""The harness process `pid` is, or None: its comm in HARNESS_COMMS (a `grok` split by /proc/<pid>/exe), else a
|
|
519
|
+
native Claude Code file (CLAUDE_EXE) as its executable."""
|
|
520
|
+
found = _stat(proc, pid)
|
|
521
|
+
if not found:
|
|
522
|
+
return None
|
|
523
|
+
harness = HARNESS_COMMS.get(found[0])
|
|
524
|
+
if harness == 'grok':
|
|
525
|
+
return 'grok' if _exe(proc, pid).endswith(GROK_BUILD_EXE) else 'grok-cli'
|
|
526
|
+
return harness or ('claude' if CLAUDE_EXE in _exe(proc, pid) else None)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def parent_harness(proc, pid):
|
|
530
|
+
"""The harness of `pid` or the first of its ancestors that is one (at most PARENT_DEPTH processes), else None."""
|
|
531
|
+
for _step in range(PARENT_DEPTH):
|
|
532
|
+
if not isinstance(pid, int) or pid <= 1:
|
|
533
|
+
return None
|
|
534
|
+
harness = process_harness(proc, pid)
|
|
535
|
+
if harness:
|
|
536
|
+
return harness
|
|
537
|
+
found = _stat(proc, pid)
|
|
538
|
+
pid = found[1] if found else 0
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def parent_environ(proc, pid, names=WHOAMI_VARS):
|
|
543
|
+
"""{name: value} of `names` in /proc/<pid>/environ when `pid` is a known harness (HARNESS_COMMS) — {} otherwise.
|
|
544
|
+
The read is bounded (ENVIRON_MAX) and never follows a link; entries are NUL-split, and one that is not UTF-8 or
|
|
545
|
+
holds no `=` is skipped. The whoami fallback for an agent that hands its MCP servers a stripped environment
|
|
546
|
+
(grok-cli, W12 DEF-1992): its own environment, set by the lane's recipe, still names the lane."""
|
|
547
|
+
if not isinstance(pid, int) or pid <= 1 or not process_harness(proc, pid):
|
|
548
|
+
return {}
|
|
549
|
+
data = read_regular(Path(proc) / str(pid) / 'environ', ENVIRON_MAX)
|
|
550
|
+
found = {}
|
|
551
|
+
for item in (data or b'').split(b'\0'):
|
|
552
|
+
key, sep, value = item.partition(b'=')
|
|
553
|
+
try:
|
|
554
|
+
key, value = key.decode('utf-8'), value.decode('utf-8')
|
|
555
|
+
except UnicodeDecodeError:
|
|
556
|
+
continue
|
|
557
|
+
if sep and key in names and value and key not in found:
|
|
558
|
+
found[key] = value
|
|
559
|
+
return found
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def whoami(root, environ=None, proc=Path('/proc'), parent=None):
|
|
473
563
|
"""Which lane this process runs in: {'lane', 'found_by', 'kind', 'entry' | 'session', 'why'?}.
|
|
474
564
|
$LANE_ID first (a canonical recipe's `-e`, or lane-run for a zmx lane); else the pane's session, asked of $TMUX's
|
|
475
565
|
socket (`display -p -t $TMUX_PANE '#S'`), matched by (socket name, session) in the registry, then among this
|
|
476
|
-
container's unregistered sessions (status.json). Not in a lane is an answer (`lane` null), not an error.
|
|
566
|
+
container's unregistered sessions (status.json). Not in a lane is an answer (`lane` null), not an error.
|
|
567
|
+
`parent` (lane-mcp passes its own parent, the agent, when it reads its own environment): when `environ` holds
|
|
568
|
+
neither LANE_ID nor TMUX/TMUX_PANE, the ones the parent's /proc environ holds stand in — only when the parent is a
|
|
569
|
+
known harness (parent_environ; grok-cli strips its MCP servers' environment, DEF-1992); `found_by` then says
|
|
570
|
+
`parent:LANE_ID` / `parent:tmux`."""
|
|
477
571
|
environ = os.environ if environ is None else environ
|
|
572
|
+
via = ''
|
|
573
|
+
if parent is not None and not environ.get('LANE_ID') and not (environ.get('TMUX') and environ.get('TMUX_PANE')):
|
|
574
|
+
inherited = parent_environ(proc, parent)
|
|
575
|
+
if inherited.get('LANE_ID') or (inherited.get('TMUX') and inherited.get('TMUX_PANE')):
|
|
576
|
+
environ, via = dict(environ, **inherited), 'parent:'
|
|
478
577
|
lanes = registry_lanes(root)
|
|
479
578
|
lane = environ.get('LANE_ID') or ''
|
|
480
579
|
if LANE_RE.fullmatch(lane):
|
|
481
580
|
entry = lanes.get(lane)
|
|
482
|
-
return {'lane': lane, 'found_by': 'LANE_ID', 'kind': lane_kind(entry) if entry else None, 'entry': entry,
|
|
581
|
+
return {'lane': lane, 'found_by': via + 'LANE_ID', 'kind': lane_kind(entry) if entry else None, 'entry': entry,
|
|
483
582
|
**({} if entry else {'why': f'LANE_ID={lane} is not in ~/.lz/registry.json (a zmx lane, or the '
|
|
484
583
|
'registry has not been synced since the lane was added)'})}
|
|
485
584
|
socket_path, pane = (environ.get('TMUX') or '').split(',', 1)[0], environ.get('TMUX_PANE') or ''
|
|
@@ -496,11 +595,12 @@ def whoami(root, environ=None):
|
|
|
496
595
|
socket = os.path.basename(socket_path)
|
|
497
596
|
for lane_id, entry in lanes.items():
|
|
498
597
|
if tmux_target(entry) == (socket, session):
|
|
499
|
-
return {'lane': lane_id, 'found_by': 'tmux', 'kind': lane_kind(entry), 'entry': entry}
|
|
598
|
+
return {'lane': lane_id, 'found_by': via + 'tmux', 'kind': lane_kind(entry), 'entry': entry}
|
|
500
599
|
for row in container_status(root)['unregistered']:
|
|
501
600
|
tmux = row.get('tmux') or {}
|
|
502
601
|
if (tmux.get('socket'), tmux.get('session')) == (socket, session):
|
|
503
|
-
return {'lane': row['id'], 'found_by': 'tmux', 'kind': 'unregistered', 'entry': None,
|
|
602
|
+
return {'lane': row['id'], 'found_by': via + 'tmux', 'kind': 'unregistered', 'entry': None,
|
|
603
|
+
'session': row}
|
|
504
604
|
return {'lane': None, 'found_by': None, 'kind': None, 'entry': None, 'tmux': {'socket': socket, 'session': session},
|
|
505
605
|
'why': 'this tmux session is neither a registry lane nor an unregistered session statusd has seen'}
|
|
506
606
|
|
|
@@ -913,8 +1013,13 @@ def terminals_add(text, terminal, entry=None):
|
|
|
913
1013
|
MCP_NAME = 'lanes'
|
|
914
1014
|
MCP_COMMAND = LZ + '/bin/lane-mcp'
|
|
915
1015
|
MCP_ENTRY = {'command': MCP_COMMAND}
|
|
916
|
-
MCP_AGENTS = ('claude', 'codex')
|
|
917
|
-
MCP_FILES = {'claude': '.claude.json', 'codex': '.codex/config.toml'
|
|
1016
|
+
MCP_AGENTS = ('claude', 'codex', 'kimi', 'grok', 'grok-cli')
|
|
1017
|
+
MCP_FILES = {'claude': '.claude.json', 'codex': '.codex/config.toml', 'kimi': '.kimi-code/mcp.json',
|
|
1018
|
+
'grok': '.grok-build/config.toml', 'grok-cli': '.grok/user-settings.json'}
|
|
1019
|
+
# W12: claude and codex get their entry in every home; kimi, grok (Grok Build, GROK_HOME) and grok-cli only where their
|
|
1020
|
+
# home directory already is a real directory (the harness is installed there), unless named (`--agent`).
|
|
1021
|
+
MCP_ALWAYS = ('claude', 'codex')
|
|
1022
|
+
MCP_HOMES = {'kimi': '.kimi-code', 'grok': '.grok-build', 'grok-cli': '.grok'}
|
|
918
1023
|
TOML_HEADER = re.compile(r'''^[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*(?:lanes|"lanes"|'lanes')[ \t]*\][ \t]*(?:#.*)?$''')
|
|
919
1024
|
# What Codex must hand lane-mcp. Codex starts a stdio MCP server with a CLEARED environment: its defaults (HOME,
|
|
920
1025
|
# LOGNAME, PATH, SHELL, USER, LANG, LC_ALL, TERM, TMPDIR, TZ) plus the names in the table's `env_vars` and the
|
|
@@ -953,26 +1058,24 @@ def _an_older_ours(entry):
|
|
|
953
1058
|
and os.path.basename(entry['command']) == 'lane-mcp' and not entry.get('args') and 'url' not in entry
|
|
954
1059
|
|
|
955
1060
|
|
|
956
|
-
def
|
|
957
|
-
"""
|
|
958
|
-
|
|
959
|
-
so). The edit is the one member, inserted in the file's own layout; the result must parse back to the input plus
|
|
960
|
-
that member, else it is refused."""
|
|
1061
|
+
def _json_mcp(text, command, what):
|
|
1062
|
+
"""`what` (a JSON file in Claude Code's `mcpServers` shape) with `mcpServers.lanes = {"command": <command>}` ->
|
|
1063
|
+
(new text, why): claude_mcp and kimi_mcp."""
|
|
961
1064
|
entry = {'command': command}
|
|
962
1065
|
if text is None or not text.strip():
|
|
963
1066
|
return json.dumps({'mcpServers': {MCP_NAME: entry}}, indent=2) + '\n', None
|
|
964
|
-
doc = strict_json(text,
|
|
1067
|
+
doc = strict_json(text, what)
|
|
965
1068
|
if not isinstance(doc, dict):
|
|
966
|
-
return text, '
|
|
1069
|
+
return text, f'{what} is not a JSON object — left alone'
|
|
967
1070
|
servers = doc.get('mcpServers')
|
|
968
1071
|
if servers is not None and not isinstance(servers, dict):
|
|
969
|
-
return text, 'mcpServers in
|
|
1072
|
+
return text, f'mcpServers in {what} is not an object — left alone'
|
|
970
1073
|
current = (servers or {}).get(MCP_NAME)
|
|
971
1074
|
if _ours(current, command):
|
|
972
1075
|
return text, None
|
|
973
1076
|
if current is not None and not _an_older_ours(current):
|
|
974
1077
|
shown = current.get('command') if isinstance(current, dict) else current
|
|
975
|
-
return text, (f'mcpServers.{MCP_NAME} in
|
|
1078
|
+
return text, (f'mcpServers.{MCP_NAME} in {what} is not the entry Lanes writes (command {shown!r}) — '
|
|
976
1079
|
'left alone (remove it to let Lanes add its own)')
|
|
977
1080
|
if servers is None:
|
|
978
1081
|
after = set_member(text, [], 'mcpServers', {MCP_NAME: entry})
|
|
@@ -980,12 +1083,27 @@ def claude_mcp(text, command=MCP_COMMAND):
|
|
|
980
1083
|
after = set_member(text, ['mcpServers'], MCP_NAME, entry)
|
|
981
1084
|
expected = dict(doc)
|
|
982
1085
|
expected['mcpServers'] = dict(servers or {}, **{MCP_NAME: entry})
|
|
983
|
-
if strict_json(after,
|
|
1086
|
+
if strict_json(after, what) != expected:
|
|
984
1087
|
return text, 'the entry could not be added without changing anything else — add it by hand'
|
|
985
1088
|
return after, None
|
|
986
1089
|
|
|
987
1090
|
|
|
988
|
-
def
|
|
1091
|
+
def claude_mcp(text, command=MCP_COMMAND):
|
|
1092
|
+
"""~/.claude.json with `mcpServers.lanes = {"command": <command>}` -> (new text, why). The text is unchanged when
|
|
1093
|
+
an equivalent entry exists; an entry of the same name that starts something else is never replaced (why says
|
|
1094
|
+
so). The edit is the one member, inserted in the file's own layout; the result must parse back to the input plus
|
|
1095
|
+
that member, else it is refused."""
|
|
1096
|
+
return _json_mcp(text, command, '~/.claude.json')
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def kimi_mcp(text, command=MCP_COMMAND):
|
|
1100
|
+
"""~/.kimi-code/mcp.json (Kimi Code's user MCP file, `resolveKimiHome()/mcp.json`; MEASURED in the 2.1.1 binary,
|
|
1101
|
+
2026-10-15: a JSON object whose `mcpServers` object maps a name to a server) the way claude_mcp edits
|
|
1102
|
+
~/.claude.json. Kimi Code starts a stdio server with its own environment, so the lane's variables arrive."""
|
|
1103
|
+
return _json_mcp(text, command, '~/.kimi-code/mcp.json')
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _toml_loads(text, what='~/.codex/config.toml'):
|
|
989
1107
|
try:
|
|
990
1108
|
import tomllib
|
|
991
1109
|
except ImportError: # Python < 3.11 on the host: the textual rules below still hold
|
|
@@ -993,7 +1111,7 @@ def _toml_loads(text):
|
|
|
993
1111
|
try:
|
|
994
1112
|
return tomllib.loads(text)
|
|
995
1113
|
except tomllib.TOMLDecodeError as error:
|
|
996
|
-
raise LaneError(f'
|
|
1114
|
+
raise LaneError(f'{what} is not valid TOML ({error}) — left alone')
|
|
997
1115
|
|
|
998
1116
|
|
|
999
1117
|
TOML_COMMAND = re.compile(r'''^[ \t]*command[ \t]*=[ \t]*"([^"\\]*)"[ \t]*(?:#.*)?$''')
|
|
@@ -1085,14 +1203,206 @@ def codex_mcp(text, command=MCP_COMMAND):
|
|
|
1085
1203
|
return after, None
|
|
1086
1204
|
|
|
1087
1205
|
|
|
1088
|
-
|
|
1206
|
+
# ------------------------------------------------------------------------------------------------ TOML marker blocks
|
|
1207
|
+
# W12: what Lanes adds to a TOML file another program owns (Kimi Code's config.toml and tui.toml, Grok Build's
|
|
1208
|
+
# config.toml) is ONE block between `# >>> lanes <name> >>>` and `# <<< lanes <name> <<<` lines: appended at the end the
|
|
1209
|
+
# first time, replaced in place after that; nothing outside the markers is ever rewritten. A table another writer put
|
|
1210
|
+
# INSIDE the block (a toml_edit writer such as Throttle's Kimi adapter appends its `[[hooks]]` after the last table,
|
|
1211
|
+
# which can be ours) is kept, moved just below the end marker; anything else inside the markers that is not a table,
|
|
1212
|
+
# and markers that are not one ordered pair, refuse the file.
|
|
1213
|
+
|
|
1214
|
+
BLOCK_BEGIN = '# >>> lanes {name} >>>'
|
|
1215
|
+
BLOCK_END = '# <<< lanes {name} <<<'
|
|
1216
|
+
_TOML_TABLE = re.compile(r'^[ \t]*\[')
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _block_rows(text, name):
|
|
1220
|
+
"""(rows, begin index, end index) of the block `name` in `text`; (rows, None, None) when it has none. LaneError
|
|
1221
|
+
when its markers are not exactly one ordered pair."""
|
|
1222
|
+
begin, end = BLOCK_BEGIN.format(name=name), BLOCK_END.format(name=name)
|
|
1223
|
+
rows = (text or '').splitlines(True)
|
|
1224
|
+
marks = [(i, row.strip()) for i, row in enumerate(rows) if row.strip() in (begin, end)]
|
|
1225
|
+
if not marks:
|
|
1226
|
+
return rows, None, None
|
|
1227
|
+
if [mark for _i, mark in marks] != [begin, end]:
|
|
1228
|
+
raise LaneError(f'the `{begin}` … `{end}` lines are not one pair — fix the file by hand', 'source_invalid')
|
|
1229
|
+
return rows, marks[0][0], marks[1][0]
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def without_block(text, name):
|
|
1233
|
+
"""`text` without the block `name` (its markers and every line between them): what the file says apart from us."""
|
|
1234
|
+
rows, first, last = _block_rows(text, name)
|
|
1235
|
+
return text or '' if first is None else ''.join(rows[:first] + rows[last + 1:])
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def toml_block(text, name, body, ours=lambda table: True):
|
|
1239
|
+
"""`text` with the block `name` holding exactly `body` (whole lines) -> new text. `ours(table text)` says whether a
|
|
1240
|
+
table found inside an existing block is ours (replaced by `body`) or another writer's (kept, moved below the
|
|
1241
|
+
block). LaneError for a key or value inside the block that belongs to no table, or for broken markers."""
|
|
1242
|
+
begin, end = BLOCK_BEGIN.format(name=name), BLOCK_END.format(name=name)
|
|
1243
|
+
block = f'{begin}\n{body}{end}\n'
|
|
1244
|
+
if text is None or not text.strip():
|
|
1245
|
+
return block
|
|
1246
|
+
rows, first, last = _block_rows(text, name)
|
|
1247
|
+
if first is None:
|
|
1248
|
+
return text + ('' if text.endswith('\n') else '\n') + '\n' + block
|
|
1249
|
+
tables = []
|
|
1250
|
+
for row in rows[first + 1:last]:
|
|
1251
|
+
if _TOML_TABLE.match(row):
|
|
1252
|
+
tables.append([row])
|
|
1253
|
+
elif tables:
|
|
1254
|
+
tables[-1].append(row)
|
|
1255
|
+
elif row.strip() and not row.lstrip().startswith('#'):
|
|
1256
|
+
raise LaneError(f'the lanes {name} block holds a line outside any table ({row.strip()[:60]!r}) — fix the '
|
|
1257
|
+
'file by hand', 'source_invalid')
|
|
1258
|
+
moved = ''.join(''.join(table) for table in tables if not ours(''.join(table)))
|
|
1259
|
+
return ''.join(rows[:first]) + block + moved + ''.join(rows[last + 1:])
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _lanes_command(table):
|
|
1263
|
+
"""A table of a lanes block that is ours: it starts lane-mcp or runs lane-signal."""
|
|
1264
|
+
return '/.lz/bin/lane-signal' in table or '/.lz/bin/lane-mcp' in table
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def grok_mcp(text, command=MCP_COMMAND):
|
|
1268
|
+
"""Grok Build's $GROK_HOME/config.toml (~/.grok-build/config.toml in a lane, DM-LANES-GROK-HOME) with a
|
|
1269
|
+
`[mcp_servers.lanes]` table that starts <command> -> (new text, why). Written as the marker block `mcp`
|
|
1270
|
+
(toml_block) rather than through `grok mcp add` (the same table; the binary need not be there, and the file edit is
|
|
1271
|
+
what a test can check). Grok Build starts a stdio server with its own environment plus GROK_SESSION_ID (MEASURED in
|
|
1272
|
+
its source, evidence 2026-10-15 §5), so no `env` is needed. A `lanes` server defined outside the block is present
|
|
1273
|
+
when it starts this command with no args, else left alone. The input must parse (tomllib) and the result must parse
|
|
1274
|
+
to the input plus that table; without tomllib any `mcp_servers` outside the block refuses."""
|
|
1275
|
+
what = '~/.grok-build/config.toml'
|
|
1276
|
+
body = f'[mcp_servers.{MCP_NAME}]\ncommand = {json.dumps(command)}\n'
|
|
1277
|
+
try:
|
|
1278
|
+
parsed = _toml_loads(text or '', what)
|
|
1279
|
+
outside = without_block(text, 'mcp')
|
|
1280
|
+
rest = _toml_loads(outside, what)
|
|
1281
|
+
except LaneError as error:
|
|
1282
|
+
return text, str(error)
|
|
1283
|
+
if rest is None:
|
|
1284
|
+
if re.search(r'(?m)^[ \t]*\[*[ \t]*mcp_servers\b|^[ \t]*mcp_servers[ \t]*[.=]', outside):
|
|
1285
|
+
return text, (f'{what} defines mcp_servers in a form this Python (no tomllib) cannot read safely — add '
|
|
1286
|
+
f'the [mcp_servers.{MCP_NAME}] table by hand')
|
|
1287
|
+
else:
|
|
1288
|
+
servers = rest.get('mcp_servers')
|
|
1289
|
+
if servers is not None and not isinstance(servers, dict):
|
|
1290
|
+
return text, f'mcp_servers in {what} is not a table — left alone'
|
|
1291
|
+
current = (servers or {}).get(MCP_NAME)
|
|
1292
|
+
if current is not None:
|
|
1293
|
+
if _ours(current, command) and parsed == rest:
|
|
1294
|
+
return text, None
|
|
1295
|
+
shown = current.get('command') if isinstance(current, dict) else current
|
|
1296
|
+
return text, (f'mcp_servers.{MCP_NAME} in {what} is not the entry Lanes writes (command {shown!r}) — '
|
|
1297
|
+
'left alone (remove it to let Lanes add its own)')
|
|
1298
|
+
try:
|
|
1299
|
+
after = toml_block(text, 'mcp', body, _lanes_command)
|
|
1300
|
+
except LaneError as error:
|
|
1301
|
+
return text, f'{what}: {error}'
|
|
1302
|
+
if rest is not None:
|
|
1303
|
+
expected = dict(rest, mcp_servers=dict(rest.get('mcp_servers') or {}, **{MCP_NAME: {'command': command}}))
|
|
1304
|
+
try:
|
|
1305
|
+
ok = _toml_loads(after, what) == expected
|
|
1306
|
+
except LaneError:
|
|
1307
|
+
ok = False
|
|
1308
|
+
if not ok:
|
|
1309
|
+
return text, 'the table could not be added without changing anything else — add it by hand'
|
|
1310
|
+
return after, None
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _grok_cli_ours(entry, command):
|
|
1314
|
+
"""A grok-cli server entry that already starts this lane-mcp: id `lanes`, stdio, our command, no args (its
|
|
1315
|
+
`enabled`, `label` and `env` are the person's to change: a disabled entry stays disabled)."""
|
|
1316
|
+
return isinstance(entry, dict) and entry.get('id') == MCP_NAME and entry.get('command') == command \
|
|
1317
|
+
and not entry.get('args') and entry.get('transport') == 'stdio' and 'url' not in entry
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def append_item(text, path, value):
|
|
1321
|
+
"""`text` with `value` appended to the array at `path` (keys from the root), laid out like its items."""
|
|
1322
|
+
root = scan(text)
|
|
1323
|
+
node = root
|
|
1324
|
+
for step in path:
|
|
1325
|
+
node = node.get(step) if node.kind == 'object' else None
|
|
1326
|
+
if node is None:
|
|
1327
|
+
raise LaneError(f'no array at {".".join(path)}', 'source_invalid')
|
|
1328
|
+
if node.kind != 'array':
|
|
1329
|
+
raise LaneError(f'no array at {".".join(path)}', 'source_invalid')
|
|
1330
|
+
return _append(text, root, node, lambda indent, unit: _render(value, indent, unit))
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def grok_cli_mcp(text, command=MCP_COMMAND):
|
|
1334
|
+
"""grok-cli's ~/.grok/user-settings.json (os.homedir()/.grok, whatever GROK_HOME says; MEASURED in the 1.1.7
|
|
1335
|
+
binary's source, 2026-10-15: `mcp.servers` is an array of {id, label, enabled, transport, command, args, env, cwd},
|
|
1336
|
+
each stdio server started as `grok-cli-<id>` with ONLY HOME, LOGNAME, PATH, SHELL, TERM and USER of grok-cli's
|
|
1337
|
+
environment plus the entry's `env`) with one server `lanes` -> (new text, why). The entry's `env` is `{}`: a file
|
|
1338
|
+
every lane shares can carry no lane's identity, and nothing secret goes in it; lane-mcp names its lane through
|
|
1339
|
+
whoami's parent fallback (grok-cli's own environment, DEF-1992). The file holds grok-cli's `apiKey`: the edit is the
|
|
1340
|
+
one member or item, every other byte kept, and the result must parse back to the input plus that entry."""
|
|
1341
|
+
what = '~/.grok/user-settings.json'
|
|
1342
|
+
entry = {'id': MCP_NAME, 'label': 'Lanes', 'enabled': True, 'transport': 'stdio', 'command': command, 'args': [],
|
|
1343
|
+
'env': {}}
|
|
1344
|
+
if text is None or not text.strip():
|
|
1345
|
+
return json.dumps({'mcp': {'servers': [entry]}}, indent=2) + '\n', None
|
|
1346
|
+
doc = strict_json(text, what)
|
|
1347
|
+
if not isinstance(doc, dict):
|
|
1348
|
+
return text, f'{what} is not a JSON object — left alone'
|
|
1349
|
+
mcp = doc.get('mcp')
|
|
1350
|
+
servers = mcp.get('servers') if isinstance(mcp, dict) else None
|
|
1351
|
+
if (mcp is not None and not isinstance(mcp, dict)) or (servers is not None and not isinstance(servers, list)):
|
|
1352
|
+
return text, f'mcp.servers in {what} is not an array — left alone'
|
|
1353
|
+
named = [i for i, item in enumerate(servers or []) if isinstance(item, dict) and item.get('id') == MCP_NAME]
|
|
1354
|
+
if len(named) > 1:
|
|
1355
|
+
return text, f'{what} lists the server id {MCP_NAME!r} twice — left alone; fix it by hand'
|
|
1356
|
+
expected = dict(doc)
|
|
1357
|
+
if named:
|
|
1358
|
+
current = servers[named[0]]
|
|
1359
|
+
if _grok_cli_ours(current, command):
|
|
1360
|
+
return text, None
|
|
1361
|
+
if not _an_older_ours(current):
|
|
1362
|
+
return text, (f'the mcp server {MCP_NAME!r} in {what} is not the entry Lanes writes (command '
|
|
1363
|
+
f'{current.get("command")!r}) — left alone (remove it to let Lanes add its own)')
|
|
1364
|
+
item = scan(text).get('mcp').get('servers').items[named[0]]
|
|
1365
|
+
root = scan(text)
|
|
1366
|
+
after = text[:item.start] + _render(entry, _line_indent(text, item.start), _file_unit(text, root)) \
|
|
1367
|
+
+ text[item.end:]
|
|
1368
|
+
expected['mcp'] = dict(mcp, servers=[entry if i == named[0] else s for i, s in enumerate(servers)])
|
|
1369
|
+
elif mcp is None:
|
|
1370
|
+
after = set_member(text, [], 'mcp', {'servers': [entry]})
|
|
1371
|
+
expected['mcp'] = {'servers': [entry]}
|
|
1372
|
+
elif servers is None:
|
|
1373
|
+
after = set_member(text, ['mcp'], 'servers', [entry])
|
|
1374
|
+
expected['mcp'] = dict(mcp, servers=[entry])
|
|
1375
|
+
else:
|
|
1376
|
+
after = append_item(text, ['mcp', 'servers'], entry)
|
|
1377
|
+
expected['mcp'] = dict(mcp, servers=list(servers) + [entry])
|
|
1378
|
+
if strict_json(after, what) != expected:
|
|
1379
|
+
return text, 'the entry could not be added without changing anything else — add it by hand'
|
|
1380
|
+
return after, None
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
MCP_EDITORS = {'claude': claude_mcp, 'codex': codex_mcp, 'kimi': kimi_mcp, 'grok': grok_mcp, 'grok-cli': grok_cli_mcp}
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def mcp_agents(home):
|
|
1387
|
+
"""The agents whose entry an install writes by default: MCP_ALWAYS, plus each of kimi, grok and grok-cli whose
|
|
1388
|
+
home directory (MCP_HOMES) is a real directory under `home` — never a link (the host writes through the volume)."""
|
|
1389
|
+
found = list(MCP_ALWAYS)
|
|
1390
|
+
for agent in MCP_AGENTS:
|
|
1391
|
+
if agent not in found:
|
|
1392
|
+
try:
|
|
1393
|
+
info = os.lstat(str(Path(home) / MCP_HOMES[agent]))
|
|
1394
|
+
except OSError:
|
|
1395
|
+
continue
|
|
1396
|
+
if stat.S_ISDIR(info.st_mode):
|
|
1397
|
+
found.append(agent)
|
|
1398
|
+
return tuple(found)
|
|
1089
1399
|
|
|
1090
1400
|
|
|
1091
|
-
def mcp_plan(home, agents=
|
|
1092
|
-
"""[{agent, path, before, after, why}] for each agent's config under `home` (pure: reads only)
|
|
1093
|
-
with no `why` = already there; a `why` = left alone, with the reason."""
|
|
1401
|
+
def mcp_plan(home, agents=None, command=MCP_COMMAND):
|
|
1402
|
+
"""[{agent, path, before, after, why}] for each agent's config under `home` (pure: reads only); `agents` defaults
|
|
1403
|
+
to mcp_agents(home). `after == before` with no `why` = already there; a `why` = left alone, with the reason."""
|
|
1094
1404
|
plan = []
|
|
1095
|
-
for agent in agents:
|
|
1405
|
+
for agent in (mcp_agents(home) if agents is None else agents):
|
|
1096
1406
|
path = Path(home) / MCP_FILES[agent]
|
|
1097
1407
|
try:
|
|
1098
1408
|
_ident, before = _snapshot(path, home)
|
|
@@ -1114,14 +1424,14 @@ def mcp_diff(item):
|
|
|
1114
1424
|
f'{path} (with the lanes MCP server)'))
|
|
1115
1425
|
|
|
1116
1426
|
|
|
1117
|
-
def mcp_apply(home, agents=
|
|
1427
|
+
def mcp_apply(home, agents=None, command=MCP_COMMAND):
|
|
1118
1428
|
"""Write each agent's entry where it is missing -> [{agent, path, status, backup, why}], status one of `added`,
|
|
1119
1429
|
`present`, `refused`. Each file is re-planned from its bytes at write time (edit_file: re-read before the
|
|
1120
1430
|
write, one retry), kept as <file>.bak-<UTC> first when it existed, replaced atomically with its own mode (a new
|
|
1121
1431
|
one 0600; a new ~/.codex is 0700). A symbolic link below `home` (the file, or ~/.codex) refuses that file.
|
|
1122
|
-
Never raises for one file: the other still gets its entry."""
|
|
1432
|
+
Never raises for one file: the other still gets its entry. `agents` defaults to mcp_agents(home)."""
|
|
1123
1433
|
results = []
|
|
1124
|
-
for agent in agents:
|
|
1434
|
+
for agent in (mcp_agents(home) if agents is None else agents):
|
|
1125
1435
|
path = Path(home) / MCP_FILES[agent]
|
|
1126
1436
|
refusal = []
|
|
1127
1437
|
|