@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.
- package/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +47 -22
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2459 -164
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +106 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1091 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1140 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +625 -15
- package/payload/lanes/docs/DESIGN.md +122 -44
- package/payload/lanes/docs/INSTALL.md +157 -17
- package/payload/lanes/docs/RUNBOOK.md +653 -81
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +792 -140
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +163 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/members.py +16 -2
- package/payload/lanes/libexec/notify.py +104 -24
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2135 -361
- package/payload/lanes/libexec/statusd.py +137 -10
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +121 -30
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +972 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/README.md +207 -25
- package/payload/lanes/web/browser.js +3 -5
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +128 -76
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- 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
|