@vibelord/lanes 1.1.0 → 1.2.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.
Files changed (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +625 -15
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +653 -81
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +792 -140
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +207 -25
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +128 -76
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
@@ -0,0 +1,1140 @@
1
+ """lane_core — what the in-container lane contract shares: `lane`, `lane-mcp` (both under ~/.lz/bin) and, on the host,
2
+ `lanes install-container`, which writes the agents' MCP entries through the same code (LNS-34).
3
+
4
+ Python 3.9+ stdlib only (the host's floor; containers run 3.12). Nothing here prints: callers decide what a person
5
+ sees, and `lane-mcp` may write nothing but protocol frames to its stdout.
6
+
7
+ * readers of the two host projections in ~/.lz: `registry.json` (`lanes sync`; DM-LANES-REGISTRY) and
8
+ `status.json` (`lanes-statusd`; DM-LANES-CONTAINER-STATUS). A newer `version`/`v` is refused (NewerVersion),
9
+ never half-read.
10
+ * one tmux runner: every client runs with `-N` (never start a server: MEASURED, tmux 3.4, `attach-session` on a
11
+ socket nobody serves starts one and leaves its socket file behind; DEF-1797), without a lane identity in its
12
+ environment (DEF-1559), with stdin closed (lane-mcp's stdin is its protocol stream) and bounded in time (exit
13
+ 124 when tmux does not answer, 127 when it cannot start). Targets are `=<session>:` (a bare `=<session>` is
14
+ refused by send-keys and capture-pane). A text and its Enter are two clients ENTER_DELAY s apart (send_keys,
15
+ DEF-1803); the one client allowed to start a server (`lanes_new`) gets creation_env(), an allow-list (DEF-1802).
16
+ * whoami: $LANE_ID, else the pane's session (`display -p -t $TMUX_PANE '#S'` on $TMUX's socket) matched in the
17
+ registry, then among the container's unregistered sessions. Codex clears an MCP server's environment, so its
18
+ entry passes these through (`env_vars`, MCP_ENV_VARS); Claude Code's servers inherit them.
19
+ * the terminals.json minimal editor: strict JSON only, every byte outside the edited span kept, re-read before the
20
+ write (one retry when the file changed meanwhile), atomic replace; never the root `throttle` object, never a
21
+ terminal's `name`, never a reorder, never the last terminal. lane-mcp's write tools (LNS-35) and, on the host,
22
+ `session_actions.unregister_declared` (the sidebar's `u` on a declared lane) edit through it.
23
+ * the host's lane-id and recipe rules, ported so the container can predict what `lanes sync` will publish: `slug`,
24
+ `shown_key`, `lane_trace`, `canonical_recipe`, the id grammar (each pinned equal to its lanes_lib original by
25
+ test_lane_mcp_write.HostRules), and `declared_name`, which finds a registry lane's declared terminal in its file.
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.
28
+ """
29
+ import difflib
30
+ import json
31
+ import os
32
+ import pwd
33
+ import re
34
+ import shlex
35
+ import stat
36
+ import subprocess
37
+ import tempfile
38
+ import time
39
+ import unicodedata
40
+ from pathlib import Path
41
+
42
+ CONTAINER_HOME = '/home/coder'
43
+ LZ = CONTAINER_HOME + '/.lz'
44
+ LANE_RE = re.compile(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}') # host lanes_lib.IDENT, bounded
45
+ STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle') # agent_state.STATUSES
46
+ NEEDS_YOU = ('needs_approval', 'errored', 'needs_input') # the sidebar's "Needs you" section (sidebar.ATTENTION)
47
+ HOOK_MAX_AGE = 3600 # statusd.SIDECAR_MAX_AGE: an older last event is no agent
48
+ STATUS_FRESH = 60 # status.json `at` is renewed at least every 15 s (statusd.HEARTBEAT); older = not live
49
+ READ_MAX = 16 << 20 # a projection bigger than this is not ours
50
+ TMUX_TIMEOUT = 5
51
+ IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE') # + a lanes/ ANTHROPIC_CUSTOM_HEADERS (identity_free)
52
+
53
+
54
+ class LaneError(Exception):
55
+ """A refusal with a sentence a person (or an agent) can act on, and, where a caller branches on it, a machine
56
+ `code` (lane-mcp's write tools answer it as `refused`; the editors below name theirs)."""
57
+
58
+ def __init__(self, message, code=None):
59
+ super().__init__(message)
60
+ self.code = code
61
+
62
+
63
+ class NewerVersion(LaneError):
64
+ """A projection written by a newer Lanes than this contract: refreshed by `lanes install-container`."""
65
+
66
+ def __init__(self, message):
67
+ super().__init__(message, 'contract_outdated')
68
+
69
+
70
+ # ------------------------------------------------------------------------------------------------ reading files
71
+
72
+ def read_regular(path, limit=READ_MAX):
73
+ """The bytes of a regular file, or None (missing, a symbolic link, not a regular file, unreadable). Opened
74
+ non-blocking and without following a link: a FIFO planted at the name can never hang a reader; a file bigger than
75
+ `limit` is not read whole (None)."""
76
+ try:
77
+ fd = os.open(str(path), os.O_RDONLY | os.O_NONBLOCK | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0))
78
+ except OSError:
79
+ return None
80
+ try:
81
+ info = os.fstat(fd)
82
+ if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
83
+ return None
84
+ chunks, size = [], 0
85
+ while True:
86
+ chunk = os.read(fd, 1 << 16)
87
+ if not chunk:
88
+ return b''.join(chunks)
89
+ size += len(chunk)
90
+ if size > limit:
91
+ return None
92
+ chunks.append(chunk)
93
+ except OSError:
94
+ return None
95
+ finally:
96
+ os.close(fd)
97
+
98
+
99
+ def read_json(path):
100
+ """A JSON document from a regular file, or None (missing, unreadable, not JSON)."""
101
+ data = read_regular(path)
102
+ if data is None:
103
+ return None
104
+ try:
105
+ return json.loads(data.decode('utf-8'))
106
+ except ValueError:
107
+ return None
108
+
109
+
110
+ def registry_lanes(root):
111
+ """{lane id: entry} of <root>/registry.json — what `lanes sync` places in this container (declared and registered
112
+ lanes). {} when there is none yet (a zmx-only container needs none). A newer registry raises NewerVersion."""
113
+ data = read_json(Path(root) / 'registry.json')
114
+ if not isinstance(data, dict):
115
+ return {}
116
+ try:
117
+ version = int(data.get('version', 1))
118
+ except (TypeError, ValueError):
119
+ return {}
120
+ if version > 1:
121
+ raise NewerVersion('the registry is newer than this `lane` — on the host run: lanes install-container <container>')
122
+ lanes = data.get('lanes')
123
+ if not isinstance(lanes, dict):
124
+ return {}
125
+ return {key: value for key, value in lanes.items() if isinstance(value, dict) and LANE_RE.fullmatch(key)}
126
+
127
+
128
+ def container_status(root, now=None):
129
+ """<root>/status.json, the slice of statusd's observation the host writes into this container:
130
+ {'available', 'why'?, 'at', 'age', 'fresh', 'lanes': {id: row}, 'unregistered': [row]}. `fresh` is False once
131
+ `at` is older than STATUS_FRESH (statusd renews it every 15 s while it runs). A newer `v` raises NewerVersion."""
132
+ now = time.time() if now is None else now
133
+ data = read_json(Path(root) / 'status.json')
134
+ if not isinstance(data, dict):
135
+ return {'available': False, 'why': 'no ~/.lz/status.json yet: the host\'s lanes-statusd writes it within a few '
136
+ 'seconds of `lanes install-container` and `lanes sync`', 'at': None, 'age': None, 'fresh': False,
137
+ 'lanes': {}, 'unregistered': []}
138
+ try:
139
+ version = int(data.get('v', 1))
140
+ at = float(data.get('at'))
141
+ except (TypeError, ValueError):
142
+ version, at = 1, None
143
+ if version > 1:
144
+ raise NewerVersion('~/.lz/status.json is newer than this contract — on the host run: '
145
+ 'lanes install-container <container>')
146
+ lanes = data.get('lanes') if isinstance(data.get('lanes'), dict) else {}
147
+ ghosts = [row for row in data.get('unregistered') or [] if isinstance(row, dict) and row.get('id')]
148
+ age = None if at is None else max(0, round(now - at, 1))
149
+ return {'available': True, 'at': at, 'age': age, 'fresh': age is not None and age <= STATUS_FRESH,
150
+ 'lanes': {key: value for key, value in lanes.items() if isinstance(value, dict)}, 'unregistered': ghosts}
151
+
152
+
153
+ def hook_agent(hooks_dir, name, proc=Path('/proc')):
154
+ """The lane's `lane-signal` sidecar (<hooks_dir>/<name>.json) as an agent dict, or {} — judged as the host's
155
+ `agent_state.read_sidecar_dict` judges it: ours (`v` 1, a vocabulary word), a last event (`at`, else `since`)
156
+ within HOOK_MAX_AGE, and a recorded agent pid still alive in this container (and the same process when the
157
+ writer recorded its `start`). A crashed agent's last word is never an agent."""
158
+ try:
159
+ d = json.loads((Path(hooks_dir) / f'{name}.json').read_text())
160
+ except (OSError, ValueError):
161
+ return {}
162
+ if not isinstance(d, dict) or d.get('v') != 1 or d.get('status') not in STATUSES:
163
+ return {}
164
+ try:
165
+ since = int(d.get('since') or 0)
166
+ at = int(d.get('at') or since)
167
+ pid = int(d.get('pid') or 0)
168
+ except (TypeError, ValueError):
169
+ return {}
170
+ if since <= 0 or time.time() - at > HOOK_MAX_AGE:
171
+ return {}
172
+ if pid > 0:
173
+ try:
174
+ text = (Path(proc) / str(pid) / 'stat').read_text()
175
+ except OSError:
176
+ return {}
177
+ if d.get('start') is not None:
178
+ fields = text.rsplit(')', 1)[-1].split()
179
+ if len(fields) < 20 or fields[19] != str(d['start']):
180
+ return {}
181
+ agent = {'harness': str(d.get('harness') or ''), 'status': d['status'], 'since': since, 'at': at,
182
+ 'event': str(d.get('event') or ''), 'session': str(d.get('session') or ''), 'pid': pid, 'source': 'hook'}
183
+ if isinstance(d.get('name'), str) and d['name']:
184
+ agent['name'] = d['name'] # the agent's own note (lanes_signal), which the host shows as its task line
185
+ return agent
186
+
187
+
188
+ def tmux_target(entry):
189
+ """(socket, session) of a registry entry held by tmux, else None."""
190
+ if not isinstance(entry, dict) or entry.get('holder') != 'tmux':
191
+ return None
192
+ tmux = entry.get('tmux') or {}
193
+ socket, session = tmux.get('socket'), tmux.get('session')
194
+ return (str(socket), str(session)) if socket and session else None
195
+
196
+
197
+ # ------------------------------------------------------------------------------------------------ the tmux runner
198
+
199
+ def identity_free(environ, drop=('TMUX',)):
200
+ """`environ` without any lane identity (lanes/docs/ARCHITECTURE.md "Lane identity"). Whatever tmux client STARTS
201
+ a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
202
+ that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1559). Board sockets are shared by
203
+ many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
204
+ An operator's own ANTHROPIC_CUSTOM_HEADERS that is not a lane trace is theirs and stays."""
205
+ return {k: v for k, v in environ.items() if k not in drop and k not in IDENTITY
206
+ and not (k == 'ANTHROPIC_CUSTOM_HEADERS' and v.startswith('x-throttle-trace: lanes/'))}
207
+
208
+
209
+ def tmux_env(environ=None):
210
+ """The environment of every tmux client: the caller's, without a lane identity and without TMUX/TMUX_PANE."""
211
+ return identity_free(os.environ if environ is None else environ, drop=('TMUX', 'TMUX_PANE'))
212
+
213
+
214
+ def tmux_argv(socket, *args):
215
+ """`tmux -N -L <socket> ARGS…` — -N: a client never starts a server (DEF-1797)."""
216
+ return ['tmux', '-N', '-L', socket, *args]
217
+
218
+
219
+ def target(session):
220
+ """The exact session, its current window: `=<session>:` (a bare `=<session>` is refused by send-keys and
221
+ capture-pane with "can't find pane", MEASURED tmux 3.4)."""
222
+ return f'={session}:'
223
+
224
+
225
+ def run_tmux(argv, timeout=None, env=None):
226
+ """One tmux client -> subprocess.CompletedProcess. Never raises: exit 124 when tmux did not answer within
227
+ `timeout` seconds (default TMUX_TIMEOUT), 127 when it could not be started. stdin is closed, stdout/stderr captured (bytes that are not
228
+ UTF-8 become U+FFFD: a pane's output never breaks the caller). `env` defaults to tmux_env() (creation_env() for
229
+ the one client that may start a server)."""
230
+ timeout = TMUX_TIMEOUT if timeout is None else timeout
231
+ try:
232
+ return subprocess.run(argv, capture_output=True, text=True, errors='replace', timeout=timeout,
233
+ env=tmux_env() if env is None else env, stdin=subprocess.DEVNULL)
234
+ except subprocess.TimeoutExpired:
235
+ return subprocess.CompletedProcess(argv, 124, '', f'tmux did not answer within {timeout} s\n')
236
+ except OSError as error:
237
+ return subprocess.CompletedProcess(argv, 127, '', f'tmux could not start: {error.strerror or error}\n')
238
+
239
+
240
+ def tmux_text(text):
241
+ """tmux's argv parser ends a command at ONE trailing `;` of ANY argument and turns a trailing `\\;` into `;`
242
+ (MEASURED, tmux 3.4: `send-keys -l -- 'echo a;'` types `echo a`). Writing that last `;` as `\\;` types the text
243
+ exactly, a trailing `\\;` included."""
244
+ return text[:-1] + '\\;' if text.endswith(';') else text
245
+
246
+
247
+ ENTER_DELAY = 0.5 # seconds between the typed text and its Enter (send_keys)
248
+
249
+
250
+ def send_argvs(socket, session, text, enter=True):
251
+ """The tmux clients that type `text` literally into the session's active pane and then press Enter: the text,
252
+ then Enter as a SECOND client. One client (`send-keys -l -- TEXT ; send-keys Enter`) hands the program in the
253
+ pane the text and the CR in a single read (MEASURED, tmux 3.4: a raw-mode reader got b'please run the tests\\r' in
254
+ one chunk), and an agent's prompt reads a multi-character chunk as a paste, CR included, so the turn never starts
255
+ (DEF-1803). send_keys runs them ENTER_DELAY seconds apart."""
256
+ typed = tmux_argv(socket, 'send-keys', '-t', target(session), '-l', '--', tmux_text(text))
257
+ return [typed, tmux_argv(socket, 'send-keys', '-t', target(session), 'Enter')] if enter else [typed]
258
+
259
+
260
+ def pause(seconds):
261
+ """time.sleep, named so a test can record the pause between a text and its Enter instead of waiting it out."""
262
+ time.sleep(seconds)
263
+
264
+
265
+ def send_keys(socket, session, text, enter=True, run=None):
266
+ """Type `text` (send_argvs), wait ENTER_DELAY seconds, press Enter -> [CompletedProcess, …] of the clients that
267
+ ran: it stops at the first that fails, so one result that failed means nothing was typed, and a failed second
268
+ means the text was typed without its Enter. `run` is the caller's tmux runner (default run_tmux)."""
269
+ run = run or run_tmux
270
+ results = []
271
+ for argv in send_argvs(socket, session, text, enter):
272
+ if results:
273
+ pause(ENTER_DELAY)
274
+ results.append(run(argv))
275
+ if results[-1].returncode:
276
+ break
277
+ return results
278
+
279
+
280
+ def capture_argv(socket, session, lines, vt=False):
281
+ """Joined scrollback of the session's active pane: the last `lines` history lines plus the screen (`-S -N`; the
282
+ caller keeps the last N non-blank lines), or all of it (`-S -`); `vt` keeps the escape sequences (`-e`)."""
283
+ argv = tmux_argv(socket, 'capture-pane', '-p', '-J', '-S', f'-{lines}' if lines else '-', '-t', target(session))
284
+ return argv + ['-e'] if vt else argv
285
+
286
+
287
+ def last_lines(text, lines):
288
+ """The last `lines` lines that end in something: a tmux capture ends with the screen's unused blank rows."""
289
+ rows = text.splitlines()
290
+ while rows and not rows[-1].strip():
291
+ rows.pop()
292
+ return '\n'.join(rows[-lines:]) + '\n' if rows else ''
293
+
294
+
295
+ def new_session_argv(socket, session, cwd, lane_id, ws, command):
296
+ """The one tmux client lane-mcp runs WITHOUT -N (`lanes_new`): it creates the session, and starts the socket's
297
+ server when none runs. The lane's identity reaches the session only through these `-e` flags, the canonical
298
+ recipe's four (the client's own environment is creation_env(), an allow-list: whatever client starts a server
299
+ hands its environment to every later session there, DEF-1559, DEF-1802). `command` is one shell-command string,
300
+ run by the server's default shell like the recipe's command."""
301
+ trace = lane_trace(lane_id, ws)
302
+ if trace is None:
303
+ raise LaneError(f'invalid lane trace: lanes/{ws}/{lane_id}', 'invalid_name')
304
+ return ['tmux', '-L', socket, 'new-session', '-d', '-s', session, '-x', '100', '-y', '30', '-c', cwd,
305
+ '-e', f'LANE_ID={lane_id}', '-e', f'LANE_WS={ws}',
306
+ '-e', f'ANTHROPIC_CUSTOM_HEADERS=x-throttle-trace: {trace}', '-e', f'THROTTLE_TRACE={trace}', command]
307
+
308
+
309
+ CREATION_ENV = ('HOME', 'USER', 'LOGNAME', 'PATH', 'LANG', 'LANGUAGE', 'TZ', 'TMUX_TMPDIR') # and every LC_*
310
+ CREATION_FIXED = {'TERM': 'xterm-256color', 'COLORTERM': 'truecolor'} # session_actions.new_shell's two `-e`
311
+
312
+
313
+ def creation_env(environ=None):
314
+ """The environment of the one client that may start a tmux server (`lanes_new`): an ALLOW-LIST, never the caller's
315
+ environment minus a deny-list (DEF-1802). The client that starts a socket's server hands its whole environment to
316
+ the server's global environment, and every later session there inherits it (DEF-1559). lane-mcp runs with the
317
+ AGENT's environment: under Claude Code that holds CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_ENTRYPOINT,
318
+ CLAUDE_CODE_MESSAGING_SOCKET/TOKEN, ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL (MEASURED in a Claude Code MCP server's
319
+ /proc/<pid>/environ, 2026-10-06), none of which may reach the shells a person later opens on that board.
320
+
321
+ The host's twin, `session_actions.new_shell`, starts the same `lanes-shells-<ws>` server through `docker exec -e
322
+ TERM=xterm-256color -e COLORTERM=truecolor`, i.e. the container's own environment and nothing of the operator's
323
+ shell. This builds the nearest equivalent from inside: the account and locale names in CREATION_ENV, every `LC_*`,
324
+ TMUX_TMPDIR when set (the socket directory), a UTF-8 LANG where the caller has none (Codex clears an MCP server's
325
+ environment), HOME from the password database where the caller has none, and the host's TERM and COLORTERM. A login
326
+ shell in the new session reads the profiles (and ~/.lz/env.sh) itself; no lane identity is in the list, so
327
+ `ANTHROPIC_CUSTOM_HEADERS` — an operator's own included — never reaches the server either."""
328
+ environ = os.environ if environ is None else environ
329
+ env = {key: value for key, value in environ.items()
330
+ if value and (key in CREATION_ENV or (key.startswith('LC_') and re.fullmatch(r'LC_[A-Z]+', key)))}
331
+ env.setdefault('LANG', 'C.UTF-8')
332
+ if 'HOME' not in env:
333
+ try:
334
+ env['HOME'] = pwd.getpwuid(os.getuid()).pw_dir
335
+ except KeyError:
336
+ env['HOME'] = CONTAINER_HOME
337
+ env.update(CREATION_FIXED)
338
+ return env
339
+
340
+
341
+ # ------------------------------------------------------------------------------------------------ the host's rules
342
+ # Ports of lanes_lib's (the host's) rules, so a write tool in the container can predict what `lanes sync` will
343
+ # publish. Each is pinned equal to its original by test_lane_mcp_write.HostRules; change both or neither.
344
+
345
+ IDENT = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_.-]*\Z') # lanes_lib.IDENT: ids, workspaces, sockets
346
+ TMUX_SESSION = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_. -]*\Z') # lanes_lib.TMUX_SESSION: a declarable session name
347
+ SAFE_PATH = re.compile(r'^/[A-Za-z0-9_./-]+\Z') # lanes_lib.SAFE_PATH: a lane folder
348
+ TRACE = re.compile(r'[A-Za-z0-9._/-]{1,256}') # lanes_lib.TRACE: the proxy's x-throttle-trace
349
+ LANE_OPEN_COMMAND = 'exec ' + LZ + '/bin/lane open ' # cursor_sync.ENTRY: a terminal's uniform command
350
+ SHELL_SOCKET = 'lanes-shells-' # session_actions.shell_socket(<ws>): a board's shells
351
+ NAME_MAX = 128 # a lane name or display name, at most
352
+
353
+
354
+ def slug(text):
355
+ """lanes_lib.slug: ASCII letters and digits of the NFKD form, every other run one `-`, lower case; `lane` when
356
+ nothing is left. A declared lane's id is `<ws>--<slug(prefix + terminal name)>` (lanes_lib.lane_id_for)."""
357
+ text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode()
358
+ text = re.sub(r'[^A-Za-z0-9]+', '-', text).strip('-').lower()
359
+ return text or 'lane'
360
+
361
+
362
+ def shown_key(value):
363
+ """lanes_lib._shown_key: a name as a row reads it (NFKC, invisible format characters dropped, whitespace runs
364
+ collapsed). Two names with one key are two rows nobody can tell apart."""
365
+ text = ''.join(ch for ch in unicodedata.normalize('NFKC', value) if unicodedata.category(ch) != 'Cf')
366
+ return ' '.join(text.split())
367
+
368
+
369
+ def lane_trace(lane_id, ws):
370
+ """lanes_lib.lane_trace: `lanes/<ws>/<id>`, or None when the proxy would reject it."""
371
+ if not isinstance(ws, str) or not isinstance(lane_id, str):
372
+ return None
373
+ trace = f'lanes/{ws}/{lane_id}'
374
+ return trace if TRACE.fullmatch(trace) else None
375
+
376
+
377
+ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
378
+ """lanes_lib.canonical_recipe: the create recipe that gives a new tmux lane its identity (LANE_ID, LANE_WS, the
379
+ lanes/<ws>/<id> trace as ANTHROPIC_CUSTOM_HEADERS and THROTTLE_TRACE) through `-e` flags; `new -A` attaches when
380
+ the session exists. LaneError where the host raises LanesError."""
381
+ trace = lane_trace(lane_id, ws)
382
+ if trace is None:
383
+ raise LaneError(f'Invalid lane trace: lanes/{ws}/{lane_id} (allowed: A-Z a-z 0-9 . _ - /, at most 256)',
384
+ 'invalid_name')
385
+ if not isinstance(socket, str) or not IDENT.match(socket):
386
+ raise LaneError(f'Invalid tmux socket: {socket!r}', 'invalid_session')
387
+ if not isinstance(session, str) or not TMUX_SESSION.match(session):
388
+ raise LaneError(f'Invalid tmux session: {session!r}', 'invalid_session')
389
+ if not isinstance(folder, str) or not folder.startswith('/'):
390
+ raise LaneError(f'Invalid lane folder: {folder!r}', 'invalid_cwd')
391
+ if not isinstance(cmd, str) or not cmd.strip():
392
+ raise LaneError('Invalid lane command', 'invalid_command')
393
+ header = shlex.quote(f'x-throttle-trace: {trace}')
394
+ return (f'tmux -L {shlex.quote(socket)} new -ADs {shlex.quote(session)} -c {shlex.quote(folder)} '
395
+ f'-e LANE_ID={lane_id} -e LANE_WS={ws} -e ANTHROPIC_CUSTOM_HEADERS={header} '
396
+ f'-e THROTTLE_TRACE={trace} {cmd}')
397
+
398
+
399
+ def lane_name_problem(name):
400
+ """Why `name` cannot name a lane, or None: one line (no control character), no leading or trailing space, at most
401
+ NAME_MAX characters, valid UTF-8 (no lone surrogate), at least one letter or digit A–Z/0–9 (the id is made of
402
+ them: a name without any would get the id `<ws>--lane`)."""
403
+ if not isinstance(name, str) or not name:
404
+ return 'a name is needed'
405
+ if any(unicodedata.category(ch) in ('Cc', 'Cs') for ch in name):
406
+ return 'a name is one line of text: no control characters'
407
+ if name != name.strip():
408
+ return 'no leading or trailing spaces'
409
+ if len(name) > NAME_MAX:
410
+ return f'at most {NAME_MAX} characters'
411
+ if not shown_key(name):
412
+ return 'a name must read as something (not only invisible characters)'
413
+ if not re.search(r'[A-Za-z0-9]', unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode()):
414
+ return 'a name needs at least one letter or digit A-Z/0-9: the lane id is made of them'
415
+ return None
416
+
417
+
418
+ def _binding(entry):
419
+ """(socketName, sessionName) of a terminals.json binding object, else None."""
420
+ tmux = entry.get('tmux') if isinstance(entry, dict) else None
421
+ if not isinstance(tmux, dict):
422
+ return None
423
+ pair = (tmux.get('socketName'), tmux.get('sessionName'))
424
+ return pair if all(isinstance(part, str) and part for part in pair) else None
425
+
426
+
427
+ def declared_name(doc, entry):
428
+ """The DECLARED terminal name (its `terminals[].name`) of a registry entry in its parsed terminals.json, or None.
429
+ First the `lanes.terminals` entry bound to the lane's tmux socket and session (`lanes cursor-sync` writes one per
430
+ terminal), then the root `throttle` block's binding, then the one terminal whose name ends the entry's
431
+ `registry_name` (prefix + declared name). None when none of these names exactly one terminal: a binding the host
432
+ keeps in the board's manifest (bindingOverrides/Recoveries) is not visible from here."""
433
+ try:
434
+ names = [name for name in _terminal_names(doc) if isinstance(name, str) and name]
435
+ except LaneError:
436
+ return None
437
+ target = tmux_target(entry)
438
+ if target:
439
+ blocks = (_lanes_terminals(doc) or {}, ((doc.get('throttle') or {}).get('terminals') or {}))
440
+ for block in blocks:
441
+ hits = [name for name in names if isinstance(block, dict) and _binding(block.get(name)) == target]
442
+ if len(hits) == 1:
443
+ return hits[0]
444
+ registry_name = entry.get('registry_name') if isinstance(entry, dict) else None
445
+ hits = [name for name in names if isinstance(registry_name, str) and registry_name.endswith(name)]
446
+ return hits[0] if len(hits) == 1 else None
447
+
448
+
449
+ def source_prefix(doc, entries):
450
+ """The repository prefix a board puts before the declared names of one terminals.json (the manifest's `prefix`,
451
+ which the container never sees), learned from that board's lanes of the file: `registry_name` minus the declared
452
+ name. None unless every lane that can be matched agrees and at least one can."""
453
+ prefixes = set()
454
+ for entry in entries:
455
+ name = declared_name(doc, entry)
456
+ registry_name = entry.get('registry_name') or ''
457
+ if name and registry_name.endswith(name):
458
+ prefixes.add(registry_name[:len(registry_name) - len(name)])
459
+ return prefixes.pop() if len(prefixes) == 1 else None
460
+
461
+
462
+ # ------------------------------------------------------------------------------------------------ whoami
463
+
464
+ def lane_kind(entry):
465
+ """declared (a terminals.json lane) or registered (a session adopted with `lanes register`)."""
466
+ return 'registered' if entry.get('registered_session') else 'declared'
467
+
468
+
469
+ def whoami(root, environ=None):
470
+ """Which lane this process runs in: {'lane', 'found_by', 'kind', 'entry' | 'session', 'why'?}.
471
+ $LANE_ID first (a canonical recipe's `-e`, or lane-run for a zmx lane); else the pane's session, asked of $TMUX's
472
+ socket (`display -p -t $TMUX_PANE '#S'`), matched by (socket name, session) in the registry, then among this
473
+ container's unregistered sessions (status.json). Not in a lane is an answer (`lane` null), not an error."""
474
+ environ = os.environ if environ is None else environ
475
+ lanes = registry_lanes(root)
476
+ lane = environ.get('LANE_ID') or ''
477
+ if LANE_RE.fullmatch(lane):
478
+ entry = lanes.get(lane)
479
+ return {'lane': lane, 'found_by': 'LANE_ID', 'kind': lane_kind(entry) if entry else None, 'entry': entry,
480
+ **({} if entry else {'why': f'LANE_ID={lane} is not in ~/.lz/registry.json (a zmx lane, or the '
481
+ 'registry has not been synced since the lane was added)'})}
482
+ socket_path, pane = (environ.get('TMUX') or '').split(',', 1)[0], environ.get('TMUX_PANE') or ''
483
+ if not socket_path or not pane:
484
+ return {'lane': None, 'found_by': None, 'kind': None, 'entry': None,
485
+ 'why': 'not inside a lane: this process has no LANE_ID and no TMUX/TMUX_PANE (an agent that clears '
486
+ 'its MCP servers\' environment must pass them: for Codex, `lane mcp install` writes the '
487
+ '[mcp_servers.lanes] env_vars)'}
488
+ result = run_tmux(['tmux', '-N', '-S', socket_path, 'display', '-p', '-t', pane, '#S'])
489
+ session = result.stdout.strip() if result.returncode == 0 else ''
490
+ if not session:
491
+ return {'lane': None, 'found_by': None, 'kind': None, 'entry': None,
492
+ 'why': f'tmux did not name this pane\'s session ({(result.stderr or "no answer").strip()})'}
493
+ socket = os.path.basename(socket_path)
494
+ for lane_id, entry in lanes.items():
495
+ if tmux_target(entry) == (socket, session):
496
+ return {'lane': lane_id, 'found_by': 'tmux', 'kind': lane_kind(entry), 'entry': entry}
497
+ for row in container_status(root)['unregistered']:
498
+ tmux = row.get('tmux') or {}
499
+ if (tmux.get('socket'), tmux.get('session')) == (socket, session):
500
+ return {'lane': row['id'], 'found_by': 'tmux', 'kind': 'unregistered', 'entry': None, 'session': row}
501
+ return {'lane': None, 'found_by': None, 'kind': None, 'entry': None, 'tmux': {'socket': socket, 'session': session},
502
+ 'why': 'this tmux session is neither a registry lane nor an unregistered session statusd has seen'}
503
+
504
+
505
+ # ------------------------------------------------------------------------------------------------ atomic files
506
+
507
+ def atomic_replace(path, text, mode):
508
+ """Replace `path` with `text` (str) atomically: an O_EXCL temp file in the same directory, fsync, chmod, rename
509
+ over whatever the name holds."""
510
+ path = Path(path)
511
+ fd, tmp = tempfile.mkstemp(prefix='.' + path.name + '.', dir=str(path.parent))
512
+ try:
513
+ with os.fdopen(fd, 'w', encoding='utf-8') as handle:
514
+ handle.write(text)
515
+ handle.flush()
516
+ os.fsync(handle.fileno())
517
+ os.chmod(tmp, mode)
518
+ os.replace(tmp, str(path))
519
+ except BaseException:
520
+ try:
521
+ os.unlink(tmp)
522
+ except OSError:
523
+ pass
524
+ raise
525
+
526
+
527
+ EDIT_MAX = 64 << 20 # ~/.claude.json grows with Claude Code's project history
528
+
529
+
530
+ def no_links(root, path):
531
+ """Refuse (LaneError) when a component of `path` below `root` is a symbolic link. The host edits the agents'
532
+ files inside a container's home volume: a link planted there resolves on the HOST (`~/.claude.json ->
533
+ /home/coder/.claude.json` would read, and copy into the container, the operator's own Claude configuration; the
534
+ DEF-1796 class)."""
535
+ path, root = Path(path), Path(root)
536
+ for depth in range(1, len(path.relative_to(root).parts) + 1):
537
+ part = root.joinpath(*path.relative_to(root).parts[:depth])
538
+ try:
539
+ if stat.S_ISLNK(os.lstat(str(part)).st_mode):
540
+ raise LaneError(f'{part} is a symbolic link — left alone', 'source_link')
541
+ except FileNotFoundError:
542
+ return
543
+
544
+
545
+ def _snapshot(path, root=None):
546
+ """(identity, text or None) of a file: identity = (inode, size, mtime_ns, mode), None when it is missing. With
547
+ `root`, no component below it may be a symbolic link (no_links)."""
548
+ if root is not None:
549
+ no_links(root, path)
550
+ try:
551
+ info = os.lstat(str(path))
552
+ except FileNotFoundError:
553
+ return None, None
554
+ data = read_regular(path, EDIT_MAX)
555
+ if data is None:
556
+ raise LaneError(f'{path}: not a regular file of at most {EDIT_MAX >> 20} MiB this account can read — left alone',
557
+ 'source_unreadable')
558
+ try:
559
+ return (info.st_ino, info.st_size, info.st_mtime_ns, stat.S_IMODE(info.st_mode)), data.decode('utf-8')
560
+ except UnicodeDecodeError:
561
+ raise LaneError(f'{path}: not UTF-8 text — left alone', 'source_unreadable')
562
+
563
+
564
+ def backup_name(path, now=None):
565
+ """<path>.bak-<UTC %Y%m%dT%H%M%SZ>, with -2, -3, … when that name is taken."""
566
+ path = Path(path)
567
+ stamp = time.strftime('%Y%m%dT%H%M%SZ', time.gmtime(now))
568
+ candidate, n = path.with_name(f'{path.name}.bak-{stamp}'), 2
569
+ while os.path.lexists(str(candidate)):
570
+ candidate, n = path.with_name(f'{path.name}.bak-{stamp}-{n}'), n + 1
571
+ return candidate
572
+
573
+
574
+ def edit_file(path, transform, new_mode=0o600, backup=False, root=None):
575
+ """Apply `transform(text or None) -> new text` to `path` and write the result atomically -> (before, after,
576
+ backup path or None); before == after means nothing was written. Re-read before the write: when the file
577
+ changed while the edit was computed (another writer — Claude Code rewrites ~/.claude.json), the edit is computed
578
+ once more from the new bytes; a second change refuses (LaneError) and writes nothing. The mode of an existing
579
+ file is kept; a new one gets `new_mode`. With `backup`, the bytes the edit was computed from are kept beside it
580
+ first (same mode). With `root`, a symbolic link at or above the file (below `root`) refuses, checked on the read
581
+ and again just before the write."""
582
+ path = Path(path)
583
+ for attempt in (1, 2):
584
+ ident, before = _snapshot(path, root)
585
+ after = transform(before)
586
+ if after == before:
587
+ return before, after, None
588
+ if _snapshot(path, root) != (ident, before):
589
+ if attempt == 1:
590
+ continue
591
+ raise LaneError(f'{path} changed twice while it was being edited — nothing written; run it again',
592
+ 'edit_conflict')
593
+ mode = ident[3] if ident else new_mode
594
+ saved = None
595
+ if backup and before is not None:
596
+ saved = backup_name(path)
597
+ fd = os.open(str(saved), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
598
+ with os.fdopen(fd, 'w', encoding='utf-8') as handle:
599
+ handle.write(before)
600
+ os.chmod(str(saved), mode)
601
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
602
+ if root is not None:
603
+ no_links(root, path) # a directory this call created is checked once more before anything lands in it
604
+ atomic_replace(path, after, mode)
605
+ return before, after, saved
606
+ raise AssertionError('unreachable')
607
+
608
+
609
+ # ------------------------------------------------------------------------------------------------ strict JSON spans
610
+
611
+ def strict_json(text, what):
612
+ """json.loads that refuses what an edit could not keep exact: comments and trailing commas (not JSON at all),
613
+ NaN/Infinity, and a key given twice in one object (an edit would change one of them)."""
614
+ def pairs(items):
615
+ seen = set()
616
+ for key, _value in items:
617
+ if key in seen:
618
+ raise LaneError(f'{what}: the key {key!r} appears twice in one object — fix it by hand first',
619
+ 'source_invalid')
620
+ seen.add(key)
621
+ return dict(items)
622
+
623
+ def constant(name):
624
+ raise LaneError(f'{what}: {name} is not JSON', 'source_invalid')
625
+ try:
626
+ return json.loads(text, object_pairs_hook=pairs, parse_constant=constant)
627
+ except ValueError as error:
628
+ raise LaneError(f'{what} is not strict JSON ({error}) — comments and trailing commas included; fix it by '
629
+ f'hand first', 'source_invalid')
630
+
631
+
632
+ class Node:
633
+ """A JSON value's span in its text: [start, end). Objects keep `members` [(key, key start, Node)], arrays
634
+ `items` [Node]."""
635
+ __slots__ = ('kind', 'start', 'end', 'members', 'items')
636
+
637
+ def __init__(self, kind, start, end=None):
638
+ self.kind, self.start, self.end, self.members, self.items = kind, start, end, [], []
639
+
640
+ def get(self, key):
641
+ for name, _key_start, value in self.members:
642
+ if name == key:
643
+ return value
644
+ return None
645
+
646
+ def entries(self):
647
+ """[(start, end)] of the members (key through value) or items, in order."""
648
+ if self.kind == 'object':
649
+ return [(key_start, value.end) for _name, key_start, value in self.members]
650
+ return [(item.start, item.end) for item in self.items]
651
+
652
+
653
+ _WS = ' \t\r\n'
654
+ _SCALAR = re.compile(r'-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|true|false|null')
655
+
656
+
657
+ def _skip(text, i):
658
+ while i < len(text) and text[i] in _WS:
659
+ i += 1
660
+ return i
661
+
662
+
663
+ def _string_end(text, i):
664
+ j = i + 1
665
+ while text[j] != '"':
666
+ j += 2 if text[j] == '\\' else 1
667
+ return j + 1
668
+
669
+
670
+ def _node(text, i):
671
+ c = text[i]
672
+ if c in '{[':
673
+ node = Node('object' if c == '{' else 'array', i)
674
+ close = '}' if c == '{' else ']'
675
+ i = _skip(text, i + 1)
676
+ if text[i] == close:
677
+ node.end = i + 1
678
+ return node
679
+ while True:
680
+ if node.kind == 'object':
681
+ key_end = _string_end(text, i)
682
+ key = json.loads(text[i:key_end])
683
+ value = _node(text, _skip(text, _skip(text, key_end) + 1))
684
+ node.members.append((key, i, value))
685
+ else:
686
+ value = _node(text, i)
687
+ node.items.append(value)
688
+ i = _skip(text, value.end)
689
+ if text[i] == ',':
690
+ i = _skip(text, i + 1)
691
+ continue
692
+ node.end = i + 1
693
+ return node
694
+ if c == '"':
695
+ return Node('string', i, _string_end(text, i))
696
+ match = _SCALAR.match(text, i)
697
+ return Node('scalar', i, match.end())
698
+
699
+
700
+ def scan(text):
701
+ """The span tree of a JSON text that strict_json accepted."""
702
+ return _node(text, _skip(text, 0))
703
+
704
+
705
+ def _line_indent(text, pos):
706
+ start = text.rfind('\n', 0, pos) + 1
707
+ end = start
708
+ while end < len(text) and text[end] in ' \t':
709
+ end += 1
710
+ return text[start:end]
711
+
712
+
713
+ def _file_unit(text, root):
714
+ """The indent step of a pretty-printed file (learned from the root's first member), None for a one-line file."""
715
+ if '\n' not in text.strip():
716
+ return None
717
+ entries = root.entries()
718
+ if entries:
719
+ sep = text[root.start + 1:entries[0][0]]
720
+ if '\n' in sep:
721
+ return sep.rsplit('\n', 1)[1] or ' '
722
+ return ' '
723
+
724
+
725
+ def _render(value, indent, unit):
726
+ if unit is None:
727
+ return json.dumps(value, ensure_ascii=False)
728
+ return json.dumps(value, ensure_ascii=False, indent=unit).replace('\n', '\n' + indent)
729
+
730
+
731
+ def _append(text, root, node, piece):
732
+ """`node` (an object or array span) with one more entry at its end, laid out like its existing entries.
733
+ `piece(indent, unit)` renders the entry ("key": value, or a value)."""
734
+ entries = node.entries()
735
+ close_indent = _line_indent(text, node.end - 1)
736
+ if not entries:
737
+ unit = _file_unit(text, root)
738
+ if unit is None:
739
+ return text[:node.start + 1] + piece('', None) + text[node.end - 1:]
740
+ indent = close_indent + unit
741
+ return text[:node.start + 1] + '\n' + indent + piece(indent, unit) + '\n' + close_indent + text[node.end - 1:]
742
+ last_start, last_end = entries[-1]
743
+ if len(entries) > 1:
744
+ sep = text[text.index(',', entries[-2][1]) + 1:last_start]
745
+ else:
746
+ sep = text[node.start + 1:last_start]
747
+ if '\n' in sep:
748
+ indent = sep.rsplit('\n', 1)[1]
749
+ unit = indent[len(close_indent):] if indent.startswith(close_indent) and len(indent) > len(close_indent) \
750
+ else (_file_unit(text, root) or ' ')
751
+ else:
752
+ indent, unit = '', None
753
+ return text[:last_end] + ',' + sep + piece(indent, unit) + text[last_end:]
754
+
755
+
756
+ def _remove(text, node, index):
757
+ """`node` without its entry `index` and the separator that belonged to it."""
758
+ entries = node.entries()
759
+ start, end = entries[index]
760
+ if len(entries) == 1:
761
+ return text[:node.start + 1] + text[node.end - 1:]
762
+ if index < len(entries) - 1:
763
+ return text[:start] + text[entries[index + 1][0]:]
764
+ return text[:entries[index - 1][1]] + text[end:]
765
+
766
+
767
+ def set_member(text, path, key, value):
768
+ """`text` with the member `key` of the object at `path` (keys from the root) set to `value`: its value span
769
+ replaced when the key exists, else one member appended. Every other byte stays."""
770
+ root = scan(text)
771
+ node = root
772
+ for step in path:
773
+ node = node.get(step) if node.kind == 'object' else None
774
+ if node is None or node.kind != 'object':
775
+ raise LaneError(f'no object at {".".join(path)}', 'source_invalid')
776
+ for name, key_start, current in node.members:
777
+ if name == key:
778
+ indent = _line_indent(text, key_start)
779
+ unit = _file_unit(text, root)
780
+ return text[:current.start] + _render(value, indent, unit) + text[current.end:]
781
+ rendered_key = json.dumps(key, ensure_ascii=False)
782
+ return _append(text, root, node, lambda indent, unit: f'{rendered_key}: {_render(value, indent, unit)}')
783
+
784
+
785
+ def remove_member(text, path, key):
786
+ root = scan(text)
787
+ node = root
788
+ for step in path:
789
+ node = node.get(step)
790
+ if node is None or node.kind != 'object':
791
+ raise LaneError(f'no object at {".".join(path)}', 'source_invalid')
792
+ for index, (name, _key_start, _value) in enumerate(node.members):
793
+ if name == key:
794
+ return _remove(text, node, index)
795
+ return text
796
+
797
+
798
+ # ------------------------------------------------------------------------------------------------ terminals.json
799
+
800
+ TERMINALS_WHAT = '.vscode/terminals.json'
801
+ DISPLAY_MAX = 128
802
+
803
+
804
+ def _terminal_names(doc):
805
+ terminals = doc.get('terminals') if isinstance(doc, dict) else None
806
+ if not isinstance(terminals, list):
807
+ raise LaneError(f'{TERMINALS_WHAT} has no "terminals" list', 'source_invalid')
808
+ return [t.get('name') if isinstance(t, dict) else None for t in terminals]
809
+
810
+
811
+ def _check_terminals(before, after, names):
812
+ """The editor's never-rules, checked on the parsed result whatever the edit was: the root `throttle` object and
813
+ every root key but `terminals`/`lanes` unchanged, the terminal names exactly `names` (no rename, no reorder) and
814
+ at least one terminal left."""
815
+ if _terminal_names(after) != names:
816
+ raise LaneError('refused: the edit would rename or reorder terminals', 'edit_refused')
817
+ if not names:
818
+ raise LaneError('refused: never the last terminal', 'last_terminal')
819
+ for key in set(before) | set(after):
820
+ if key not in ('terminals', 'lanes') and before.get(key) != after.get(key):
821
+ raise LaneError(f'refused: the edit would change the root {key!r} object', 'edit_refused')
822
+
823
+
824
+ def _lanes_terminals(doc):
825
+ block = doc.get('lanes')
826
+ entries = block.get('terminals') if isinstance(block, dict) else None
827
+ return entries if isinstance(entries, dict) else None
828
+
829
+
830
+ def terminals_set_display(text, name, display):
831
+ """terminals.json with `lanes.terminals[<name>].displayName` set to `display` (None removes it). The declared
832
+ `name` — what the lane id derives from — never changes (LNS-33: the host reads displayName leniently, the id
833
+ never moves)."""
834
+ doc = strict_json(text, TERMINALS_WHAT)
835
+ names = _terminal_names(doc)
836
+ if name not in names:
837
+ raise LaneError(f'{TERMINALS_WHAT} declares no terminal named {name!r}', 'not_declared_here')
838
+ entries = _lanes_terminals(doc)
839
+ if entries is None or not isinstance(entries.get(name), dict):
840
+ raise LaneError(f'{TERMINALS_WHAT} has no lanes.terminals entry for {name!r} — run `lanes cursor-sync '
841
+ f'--write` on the host first', 'no_lanes_block')
842
+ if display is not None:
843
+ if not isinstance(display, str) or not display.strip() or re.search(r'[\x00-\x1f\x7f]', display) \
844
+ or len(display) > DISPLAY_MAX:
845
+ raise LaneError(f'invalid display name: one line of text, at most {DISPLAY_MAX} characters', 'invalid_name')
846
+ after = set_member(text, ['lanes', 'terminals', name], 'displayName', display)
847
+ else:
848
+ after = remove_member(text, ['lanes', 'terminals', name], 'displayName')
849
+ parsed = strict_json(after, TERMINALS_WHAT)
850
+ _check_terminals(doc, parsed, names)
851
+ if _lanes_terminals(parsed)[name].get('displayName') != display:
852
+ raise LaneError('refused: the edit did not land where it should', 'edit_refused')
853
+ return after
854
+
855
+
856
+ def terminals_remove(text, name):
857
+ """terminals.json without the terminal `name`: its `terminals[]` item and its `lanes.terminals` entry. Never the
858
+ last terminal; the root `throttle` object (Throttle's annotations) is left exactly as it is."""
859
+ doc = strict_json(text, TERMINALS_WHAT)
860
+ names = _terminal_names(doc)
861
+ if names.count(name) != 1:
862
+ raise LaneError(f'{TERMINALS_WHAT} declares {names.count(name)} terminals named {name!r}; refusing',
863
+ 'not_declared_here' if not names.count(name) else 'source_invalid')
864
+ if len(names) == 1:
865
+ raise LaneError(f'{name!r} is the last terminal of {TERMINALS_WHAT}: never the last terminal', 'last_terminal')
866
+ root = scan(text)
867
+ after = _remove(text, root.get('terminals'), names.index(name))
868
+ if name in (_lanes_terminals(doc) or {}):
869
+ after = remove_member(after, ['lanes', 'terminals'], name)
870
+ parsed = strict_json(after, TERMINALS_WHAT)
871
+ _check_terminals(doc, parsed, [n for n in names if n != name])
872
+ if name in (_lanes_terminals(parsed) or {}):
873
+ raise LaneError('refused: the edit did not land where it should', 'edit_refused')
874
+ return after
875
+
876
+
877
+ def terminals_add(text, terminal, entry=None):
878
+ """terminals.json with `terminal` (a VS Code terminal object with a new `name`) appended to `terminals[]` and,
879
+ when given, `entry` as its `lanes.terminals[<name>]` (the block must exist). Nothing is reordered. A binding the
880
+ file already holds — in `lanes.terminals` or the root `throttle` block, under any name — is refused
881
+ (`duplicate_binding`): two declarations of one session would make two lanes of it."""
882
+ doc = strict_json(text, TERMINALS_WHAT)
883
+ names = _terminal_names(doc)
884
+ name = terminal.get('name') if isinstance(terminal, dict) else None
885
+ if not isinstance(name, str) or not name.strip() or re.search(r'[\x00-\x1f\x7f]', name):
886
+ raise LaneError('a terminal needs a one-line name', 'invalid_name')
887
+ if name in names:
888
+ raise LaneError(f'{TERMINALS_WHAT} already declares {name!r}', 'duplicate_name')
889
+ pair = _binding(entry)
890
+ if pair:
891
+ for block in (_lanes_terminals(doc) or {}, ((doc.get('throttle') or {}).get('terminals') or {})):
892
+ held = [other for other, value in (block.items() if isinstance(block, dict) else ()) if _binding(value) == pair]
893
+ if held:
894
+ raise LaneError(f'{TERMINALS_WHAT} already binds tmux {pair[0]}/{pair[1]} (as {held[0]!r})',
895
+ 'duplicate_binding')
896
+ root = scan(text)
897
+ after = _append(text, root, root.get('terminals'), lambda indent, unit: _render(terminal, indent, unit))
898
+ if entry is not None:
899
+ if _lanes_terminals(doc) is None:
900
+ raise LaneError(f'{TERMINALS_WHAT} has no lanes block — run `lanes cursor-sync --write` on the host first',
901
+ 'no_lanes_block')
902
+ after = set_member(after, ['lanes', 'terminals'], name, entry)
903
+ parsed = strict_json(after, TERMINALS_WHAT)
904
+ _check_terminals(doc, parsed, names + [name])
905
+ return after
906
+
907
+
908
+ # ------------------------------------------------------------------------------------------------ MCP entries
909
+
910
+ MCP_NAME = 'lanes'
911
+ MCP_COMMAND = LZ + '/bin/lane-mcp'
912
+ MCP_ENTRY = {'command': MCP_COMMAND}
913
+ MCP_AGENTS = ('claude', 'codex')
914
+ MCP_FILES = {'claude': '.claude.json', 'codex': '.codex/config.toml'}
915
+ TOML_HEADER = re.compile(r'''^[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*(?:lanes|"lanes"|'lanes')[ \t]*\][ \t]*(?:#.*)?$''')
916
+ # What Codex must hand lane-mcp. Codex starts a stdio MCP server with a CLEARED environment: its defaults (HOME,
917
+ # LOGNAME, PATH, SHELL, USER, LANG, LC_ALL, TERM, TMPDIR, TZ) plus the names in the table's `env_vars` and the
918
+ # table's own `env` (codex-rs rmcp-client `create_env_for_mcp_server`; MEASURED codex-cli 0.160.1, 2026-10-06: a
919
+ # table without `env_vars` started its server with HOME, PATH and USER only, the same table with this list also with
920
+ # all four). whoami reads LANE_ID, TMUX and TMUX_PANE; LANE_WS completes the lane identity the canonical recipe
921
+ # stamps, so lane-mcp sees under Codex what it sees under Claude Code, which inherits its whole environment.
922
+ MCP_ENV_VARS = ('LANE_ID', 'LANE_WS', 'TMUX', 'TMUX_PANE')
923
+
924
+
925
+ def _ours(entry, command):
926
+ """An MCP entry that already starts this lane-mcp: our command, no arguments, stdio."""
927
+ return isinstance(entry, dict) and entry.get('command') == command and not entry.get('args') \
928
+ and entry.get('type', 'stdio') == 'stdio' and 'url' not in entry
929
+
930
+
931
+ def _codex_passed(entry):
932
+ """The variable names a parsed Codex table passes through (`env_vars`: plain names, or {name, source} tables of
933
+ a local source)."""
934
+ names = entry.get('env_vars') if isinstance(entry, dict) else None
935
+ if not isinstance(names, list):
936
+ return []
937
+ return [item if isinstance(item, str) else item.get('name') for item in names
938
+ if isinstance(item, str) or (isinstance(item, dict) and isinstance(item.get('name'), str)
939
+ and item.get('source', 'local') == 'local')]
940
+
941
+
942
+ def _codex_ours(entry, command):
943
+ """A Codex table that already starts this lane-mcp AND passes it every name in MCP_ENV_VARS (more are fine)."""
944
+ return _ours(entry, command) and set(MCP_ENV_VARS) <= set(_codex_passed(entry))
945
+
946
+
947
+ def _an_older_ours(entry):
948
+ """An entry an earlier install wrote: a lane-mcp at another path (it is replaced)."""
949
+ return isinstance(entry, dict) and isinstance(entry.get('command'), str) \
950
+ and os.path.basename(entry['command']) == 'lane-mcp' and not entry.get('args') and 'url' not in entry
951
+
952
+
953
+ def claude_mcp(text, command=MCP_COMMAND):
954
+ """~/.claude.json with `mcpServers.lanes = {"command": <command>}` -> (new text, why). The text is unchanged when
955
+ an equivalent entry exists; an entry of the same name that starts something else is never replaced (why says
956
+ so). The edit is the one member, inserted in the file's own layout; the result must parse back to the input plus
957
+ that member, else it is refused."""
958
+ entry = {'command': command}
959
+ if text is None or not text.strip():
960
+ return json.dumps({'mcpServers': {MCP_NAME: entry}}, indent=2) + '\n', None
961
+ doc = strict_json(text, '~/.claude.json')
962
+ if not isinstance(doc, dict):
963
+ return text, '~/.claude.json is not a JSON object — left alone'
964
+ servers = doc.get('mcpServers')
965
+ if servers is not None and not isinstance(servers, dict):
966
+ return text, 'mcpServers in ~/.claude.json is not an object — left alone'
967
+ current = (servers or {}).get(MCP_NAME)
968
+ if _ours(current, command):
969
+ return text, None
970
+ if current is not None and not _an_older_ours(current):
971
+ shown = current.get('command') if isinstance(current, dict) else current
972
+ return text, (f'mcpServers.{MCP_NAME} in ~/.claude.json is not the entry Lanes writes (command {shown!r}) — '
973
+ 'left alone (remove it to let Lanes add its own)')
974
+ if servers is None:
975
+ after = set_member(text, [], 'mcpServers', {MCP_NAME: entry})
976
+ else:
977
+ after = set_member(text, ['mcpServers'], MCP_NAME, entry)
978
+ expected = dict(doc)
979
+ expected['mcpServers'] = dict(servers or {}, **{MCP_NAME: entry})
980
+ if strict_json(after, '~/.claude.json') != expected:
981
+ return text, 'the entry could not be added without changing anything else — add it by hand'
982
+ return after, None
983
+
984
+
985
+ def _toml_loads(text):
986
+ try:
987
+ import tomllib
988
+ except ImportError: # Python < 3.11 on the host: the textual rules below still hold
989
+ return None
990
+ try:
991
+ return tomllib.loads(text)
992
+ except tomllib.TOMLDecodeError as error:
993
+ raise LaneError(f'~/.codex/config.toml is not valid TOML ({error}) — left alone')
994
+
995
+
996
+ TOML_COMMAND = re.compile(r'''^[ \t]*command[ \t]*=[ \t]*"([^"\\]*)"[ \t]*(?:#.*)?$''')
997
+ TOML_ENV_VARS_KEY = re.compile(r'''^[ \t]*(?:env_vars|"env_vars"|'env_vars')[ \t]*=''')
998
+ _TOML_NAME = r'[ \t]*"[A-Za-z_][A-Za-z0-9_]*"[ \t]*'
999
+ TOML_ENV_VARS = re.compile(rf'''^[ \t]*env_vars[ \t]*=[ \t]*\[((?:{_TOML_NAME},)*(?:{_TOML_NAME})?)[ \t]*\][ \t]*(?:#.*)?$''')
1000
+
1001
+
1002
+ def _env_vars_line(names):
1003
+ return f'env_vars = {json.dumps(list(names))}'
1004
+
1005
+
1006
+ def codex_mcp(text, command=MCP_COMMAND):
1007
+ """~/.codex/config.toml with a `[mcp_servers.lanes]` table whose `command` is <command> and whose `env_vars`
1008
+ passes MCP_ENV_VARS -> (new text, why). A missing table is appended at the end (a new table header after
1009
+ everything else changes no other key); an earlier install's table (a lane-mcp at another path, or `codex mcp
1010
+ add`'s spelling: a command and no `env_vars`) gets its one `command` line replaced and/or the `env_vars` line
1011
+ inserted right after it, or the missing names appended to its one-line `env_vars` list; a `lanes` server that
1012
+ starts something else, one not written as its own `[mcp_servers.lanes]` table with one plain `command = "…"`
1013
+ line, or one whose `env_vars` is not a one-line list of plain names, is left alone. The decision is read from
1014
+ the text itself, so a host without tomllib (Python < 3.11) decides the same way for the forms it can read;
1015
+ with tomllib the input must parse and the result must parse back to the input plus that table."""
1016
+ line = f'command = {json.dumps(command)}'
1017
+ if text is None or not text.strip():
1018
+ return f'[mcp_servers.{MCP_NAME}]\n{line}\n{_env_vars_line(MCP_ENV_VARS)}\n', None
1019
+ try:
1020
+ parsed = _toml_loads(text)
1021
+ except LaneError as error:
1022
+ return text, str(error)
1023
+ rows = text.splitlines(True)
1024
+ headers = [i for i, row in enumerate(rows) if TOML_HEADER.match(row.rstrip('\r\n'))]
1025
+ names = list(MCP_ENV_VARS)
1026
+ if parsed is not None:
1027
+ servers = parsed.get('mcp_servers')
1028
+ current = servers.get(MCP_NAME) if isinstance(servers, dict) else None
1029
+ if current is not None and _codex_ours(current, command):
1030
+ return text, None
1031
+ elif re.search(r'(?m)^[ \t]*(mcp_servers[ \t]*[.=]|\[[ \t]*mcp_servers[ \t]*\])', text):
1032
+ return text, ('~/.codex/config.toml defines mcp_servers in a form this Python (no tomllib) cannot read '
1033
+ 'safely — add the table by hand')
1034
+ else:
1035
+ current = {} if headers else None
1036
+ if current is None and not headers:
1037
+ after = text + ('' if text.endswith('\n') else '\n') + \
1038
+ f'\n[mcp_servers.{MCP_NAME}]\n{line}\n{_env_vars_line(names)}\n'
1039
+ else:
1040
+ if len(headers) != 1:
1041
+ return text, (f'mcp_servers.{MCP_NAME} in ~/.codex/config.toml is not written as one '
1042
+ f'[mcp_servers.{MCP_NAME}] table — left alone; fix it by hand')
1043
+ start = headers[0] + 1
1044
+ end = next((i for i in range(start, len(rows)) if rows[i].lstrip().startswith('[')), len(rows))
1045
+ hits = [i for i in range(start, end) if re.match(r'[ \t]*command[ \t]*=', rows[i])]
1046
+ found = TOML_COMMAND.match(rows[hits[0]].rstrip('\r\n')) if len(hits) == 1 else None
1047
+ if not found:
1048
+ return text, (f'[mcp_servers.{MCP_NAME}] in ~/.codex/config.toml has no single plain command line — '
1049
+ 'left alone; fix it by hand')
1050
+ if os.path.basename(found.group(1)) != 'lane-mcp' or (parsed is not None and not _an_older_ours(current)):
1051
+ return text, (f'mcp_servers.{MCP_NAME} in ~/.codex/config.toml is not the entry Lanes writes (command '
1052
+ f'{found.group(1)!r}) — left alone (remove it to let Lanes add its own)')
1053
+ passes = [i for i in range(start, end) if TOML_ENV_VARS_KEY.match(rows[i])]
1054
+ listed = TOML_ENV_VARS.match(rows[passes[0]].rstrip('\r\n')) if len(passes) == 1 else None
1055
+ if passes and not listed:
1056
+ return text, (f'[mcp_servers.{MCP_NAME}] in ~/.codex/config.toml has an env_vars that is not one line '
1057
+ f'of plain names — left alone; make it pass {", ".join(MCP_ENV_VARS)} by hand')
1058
+ have = re.findall(r'"([^"]*)"', listed.group(1)) if listed else None
1059
+ if have is not None:
1060
+ names = have + [name for name in MCP_ENV_VARS if name not in have]
1061
+ if found.group(1) == command and have == names:
1062
+ return text, None
1063
+ at = hits[0]
1064
+ ending = rows[at][len(rows[at].rstrip('\r\n')):]
1065
+ rows[at] = line + ((ending or '\n') if have is None else ending)
1066
+ if have is None: # `codex mcp add`'s spelling, or an install from before the pass-through: one line more
1067
+ rows.insert(at + 1, _env_vars_line(names) + ending)
1068
+ elif have != names:
1069
+ kept = rows[passes[0]]
1070
+ rows[passes[0]] = _env_vars_line(names) + kept[len(kept.rstrip('\r\n')):]
1071
+ after = ''.join(rows)
1072
+ if parsed is not None:
1073
+ expected = dict(parsed)
1074
+ expected['mcp_servers'] = dict(parsed.get('mcp_servers') or {})
1075
+ expected['mcp_servers'][MCP_NAME] = dict(current or {}, command=command, env_vars=names)
1076
+ try:
1077
+ ok = _toml_loads(after) == expected
1078
+ except LaneError:
1079
+ ok = False
1080
+ if not ok:
1081
+ return text, 'the table could not be added without changing anything else — add it by hand'
1082
+ return after, None
1083
+
1084
+
1085
+ MCP_EDITORS = {'claude': claude_mcp, 'codex': codex_mcp}
1086
+
1087
+
1088
+ def mcp_plan(home, agents=MCP_AGENTS, command=MCP_COMMAND):
1089
+ """[{agent, path, before, after, why}] for each agent's config under `home` (pure: reads only). `after == before`
1090
+ with no `why` = already there; a `why` = left alone, with the reason."""
1091
+ plan = []
1092
+ for agent in agents:
1093
+ path = Path(home) / MCP_FILES[agent]
1094
+ try:
1095
+ _ident, before = _snapshot(path, home)
1096
+ after, why = MCP_EDITORS[agent](before, command)
1097
+ except LaneError as error:
1098
+ before = after = None
1099
+ why = str(error)
1100
+ plan.append({'agent': agent, 'path': path, 'before': before, 'after': after, 'why': why})
1101
+ return plan
1102
+
1103
+
1104
+ def mcp_diff(item):
1105
+ """The unified diff of one plan item ('' when there is nothing to change)."""
1106
+ if item['why'] or item['after'] == item['before']:
1107
+ return ''
1108
+ before, after = item['before'] or '', item['after'] or ''
1109
+ path = str(item['path'])
1110
+ return ''.join(difflib.unified_diff(before.splitlines(True), after.splitlines(True), f'{path} (now)',
1111
+ f'{path} (with the lanes MCP server)'))
1112
+
1113
+
1114
+ def mcp_apply(home, agents=MCP_AGENTS, command=MCP_COMMAND):
1115
+ """Write each agent's entry where it is missing -> [{agent, path, status, backup, why}], status one of `added`,
1116
+ `present`, `refused`. Each file is re-planned from its bytes at write time (edit_file: re-read before the
1117
+ write, one retry), kept as <file>.bak-<UTC> first when it existed, replaced atomically with its own mode (a new
1118
+ one 0600; a new ~/.codex is 0700). A symbolic link below `home` (the file, or ~/.codex) refuses that file.
1119
+ Never raises for one file: the other still gets its entry."""
1120
+ results = []
1121
+ for agent in agents:
1122
+ path = Path(home) / MCP_FILES[agent]
1123
+ refusal = []
1124
+
1125
+ def transform(before, agent=agent, refusal=refusal):
1126
+ after, why = MCP_EDITORS[agent](before, command)
1127
+ del refusal[:]
1128
+ if why:
1129
+ refusal.append(why)
1130
+ return after
1131
+ try:
1132
+ before, after, saved = edit_file(path, transform, new_mode=0o600, backup=True, root=home)
1133
+ except (LaneError, OSError) as error:
1134
+ results.append({'agent': agent, 'path': path, 'status': 'refused', 'backup': None,
1135
+ 'why': str(error)})
1136
+ continue
1137
+ status = 'refused' if refusal else ('present' if before == after else 'added')
1138
+ results.append({'agent': agent, 'path': path, 'status': status, 'backup': saved,
1139
+ 'why': refusal[0] if refusal else None})
1140
+ return results