@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.
@@ -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, planned as a diff, written with a `.bak-<UTC>` copy, atomically and only when missing.
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') # + a lanes/ ANTHROPIC_CUSTOM_HEADERS (identity_free)
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 == 'ANTHROPIC_CUSTOM_HEADERS' and v.startswith('x-throttle-trace: lanes/'))}
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 four (the client's own environment is creation_env(), an allow-list: whatever client starts a server
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}', command]
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 and THROTTLE_TRACE) through `-e` flags; `new -A` attaches when
383
- the session exists. LaneError where the host raises LanesError."""
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} {cmd}')
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
- def whoami(root, environ=None):
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, 'session': row}
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 claude_mcp(text, command=MCP_COMMAND):
957
- """~/.claude.json with `mcpServers.lanes = {"command": <command>}` -> (new text, why). The text is unchanged when
958
- an equivalent entry exists; an entry of the same name that starts something else is never replaced (why says
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, '~/.claude.json')
1067
+ doc = strict_json(text, what)
965
1068
  if not isinstance(doc, dict):
966
- return text, '~/.claude.json is not a JSON object — left alone'
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 ~/.claude.json is not an object — left alone'
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 ~/.claude.json is not the entry Lanes writes (command {shown!r}) — '
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, '~/.claude.json') != expected:
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 _toml_loads(text):
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'~/.codex/config.toml is not valid TOML ({error}) — left alone')
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
- MCP_EDITORS = {'claude': claude_mcp, 'codex': codex_mcp}
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=MCP_AGENTS, command=MCP_COMMAND):
1092
- """[{agent, path, before, after, why}] for each agent's config under `home` (pure: reads only). `after == before`
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=MCP_AGENTS, command=MCP_COMMAND):
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