@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,1091 @@
1
+ #!/usr/bin/env python3
2
+ """lane-mcp — the Lanes Agent API inside a workspace container: an MCP server on stdin/stdout (LNS-34/35, wire v1).
3
+ Installed at /home/coder/.lz/bin/lane-mcp by `lanes install-container`; Claude Code and Codex start it from
4
+ `mcpServers.lanes` in ~/.claude.json and `[mcp_servers.lanes]` in ~/.codex/config.toml (`lane mcp install`).
5
+
6
+ usage: lane-mcp (speaks MCP on stdin/stdout; nothing else to run by hand)
7
+
8
+ Protocol: newline-delimited JSON-RPC 2.0, one message per line (no batches). `initialize` answers the client's
9
+ protocolVersion when it is one of 2025-06-18, 2025-03-26 or 2024-11-05, else 2025-06-18; then
10
+ `notifications/initialized`, `ping`, `tools/list` (a fixed order, JSON Schema inputs, annotations) and
11
+ `tools/call`. Only `initialize` and `ping` are answered before `initialize`. An unknown method is -32601, an unknown
12
+ tool -32602; a tool that cannot do its job answers `isError: true` with the reason as text, so the agent can act on
13
+ it. Stdout carries nothing but protocol frames: the real fd 1 is kept for them and fd 1 itself points at /dev/null
14
+ before anything runs, so neither a stray print nor a child process can corrupt the stream. One line per request
15
+ goes to ~/.lz/s/logs/lane-mcp.log (methods, tool names, lane ids, outcomes and refusal codes, durations; never a
16
+ capture's text, a sent text or a note), rotated to .1 past 1 MiB.
17
+
18
+ Read tools (the lanes of THIS container: what ~/.lz/registry.json and ~/.lz/status.json hold):
19
+ lanes_whoami which lane this agent runs in ($LANE_ID, else its tmux pane's session)
20
+ lanes_list [workspace] [needs_you] [agent] every lane here with its state and agent
21
+ lanes_status <lane> one lane in full: registry facts, observed state, agent, tmux liveness, hook sidecar
22
+ lanes_capture <lane> [lines] the last N (1–500, default 100) non-blank lines of that lane's screen and scrollback
23
+
24
+ Write tools (LNS-35). Each answers JSON: on success what changed, what picks it up (`picked_up_by`) and when
25
+ (`expected_within_s`); on a refusal `isError: true` with {refused: <machine code>, why, human: what a person can do
26
+ instead}. The declaration tools edit a repository's .vscode/terminals.json through lane_core's minimal editor (one
27
+ span, re-read before the atomic write, never the root `throttle` object, never a rename or reorder, never the last
28
+ terminal); `lanes sync` publishes the change (lanes-statusd starts one within ~15 s of the edit).
29
+ lanes_register <name> [session] [repo] declare an unregistered tmux session (the caller's, or a named one) as a lane
30
+ lanes_unregister <lane> remove a declared lane's terminal; its session keeps running (an unregistered session)
31
+ lanes_rename <lane> <display_name> the lane's displayName in terminals.json; the id never changes
32
+ lanes_new <name> [cwd] [command] [workspace] [repo] declare a lane on lanes-shells-<ws> and start its session
33
+ lanes_send <lane> <text> [enter] [force] type printable text (then, half a second later, Enter) into a lane here
34
+ lanes_signal <status> [note] report this agent's own status (and a one-line note) through lane-signal; the report
35
+ stands until the agent's next tool call, prompt or permission prompt, and the end of
36
+ its turn keeps it (lane-signal's EXPLICIT rule)
37
+ Human-only by omission (no tool exists): ending or closing a lane, interrupting it (C-c or any key that is not
38
+ printable text), opening or attaching one, uploads, the host's sync/gc/ensure, renaming a tmux session, and any
39
+ edit of an adopted (`lanes register`ed, `--x-`) session — those stay with a person on the boards or the host.
40
+ """
41
+ import json
42
+ import os
43
+ import re
44
+ import subprocess
45
+ import sys
46
+ import time
47
+ import traceback
48
+ import unicodedata
49
+ from pathlib import Path
50
+
51
+ sys.dont_write_bytecode = True # ~/.lz/bin holds no __pycache__
52
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
53
+ import lane_core as C # noqa: E402
54
+
55
+ PROTOCOLS = ('2025-06-18', '2025-03-26', '2024-11-05') # the first is the one offered to an unknown version
56
+ WIRE = 1
57
+ LINE_MAX = 4 << 20
58
+ LOG_MAX = 1 << 20
59
+ CAPTURE_DEFAULT, CAPTURE_MAX = 100, 500
60
+ TEXT_MAX = 256 << 10
61
+ PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR = -32700, -32600, -32601, -32602, -32603
62
+ NOT_INITIALIZED = -32002
63
+ LOG_HINT = '~/.lz/s/logs/lane-mcp.log'
64
+ SEND_MAX = 4000 # characters lanes_send types in one call
65
+ NOTE_MAX = 120 # lane-signal's NOTE_MAX
66
+ PATH_MAX, COMMAND_MAX = 1024, 1024
67
+ # When a write reaches the boards. A terminals.json edit: lanes-statusd checks the files its registry names every
68
+ # cycle (3 s) and starts ONE `lanes sync` at most every 10 s (statusd.hot_sync), which then publishes; the timer's
69
+ # own sync runs every 2 minutes regardless. A hook sidecar: read on the next statusd cycle (3 s, slower under
70
+ # memory pressure) and published with it.
71
+ SYNC_LATENCY_S, STATUS_LATENCY_S = 15, 5
72
+ SYNC_PICKUP = 'lanes sync'
73
+ STATUS_PICKUP = 'lanes-statusd'
74
+ SESSION_VARS = ('CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID') # the agent's session id, when its env carries one
75
+ # How long a lanes_signal report holds against the agent's own hooks (lane-signal's EXPLICIT rule, DEF-1801).
76
+ SIGNAL_STANDS = ('your next tool call, a new prompt, a permission prompt, a failed turn or the session\'s end; the end '
77
+ 'of your turn keeps it')
78
+ SIGNAL_WORKING = 'the end of your turn (the note stays while you keep working)'
79
+
80
+ INSTRUCTIONS = ('Lanes tools for the workspace container this agent runs in. A lane is one long-lived agent terminal '
81
+ '(a tmux session) shown on the Lanes boards. lanes_whoami names your own lane; lanes_list shows every '
82
+ 'lane in this container with its observed state and agent (needs_you: true lists the lanes waiting '
83
+ 'for a person); lanes_status gives one lane in full; lanes_capture reads the last lines of another '
84
+ 'lane\'s screen. Write tools: lanes_register declares an unregistered tmux session as a lane, '
85
+ 'lanes_unregister removes a declared lane (its session keeps running), lanes_rename sets a lane\'s '
86
+ 'display name, lanes_new declares and starts a new lane, lanes_send types text into a lane (refused '
87
+ 'while its agent is working or waiting for an approval unless force: true), lanes_signal reports '
88
+ 'your own status with a short note. Every write answers what changed, what picks it up and when; a '
89
+ 'refusal answers a machine code and what a person can do instead. Ending, interrupting or attaching '
90
+ 'a lane is a person\'s call: no tool does it.')
91
+
92
+ ID_PATTERN = '^[A-Za-z0-9_][A-Za-z0-9_.-]*$'
93
+ LANE_ARG = {'type': 'string', 'pattern': ID_PATTERN, 'maxLength': 128,
94
+ 'description': 'A lane id as lanes_list shows it, e.g. "bw-tools--lanes-zellij".'}
95
+ READ_ONLY = {'readOnlyHint': True, 'destructiveHint': False, 'idempotentHint': True, 'openWorldHint': False}
96
+
97
+
98
+ def _tool(name, title, description, properties, required=(), hints=READ_ONLY):
99
+ schema = {'type': 'object', 'properties': properties, 'additionalProperties': False}
100
+ if required:
101
+ schema['required'] = list(required)
102
+ return {'name': name, 'title': title, 'description': description, 'inputSchema': schema,
103
+ 'annotations': {'title': title, **hints}}
104
+
105
+
106
+ def _writes(destructive, idempotent):
107
+ """A write tool's hints. A tool that creates something (register, new, send) is never marked idempotent: a
108
+ repeat is refused or acts again, never answered the same."""
109
+ return {'readOnlyHint': False, 'destructiveHint': destructive, 'idempotentHint': idempotent,
110
+ 'openWorldHint': False}
111
+
112
+
113
+ NAME_ARG = {'type': 'string', 'minLength': 1, 'maxLength': C.NAME_MAX,
114
+ 'description': 'The lane\'s name as the boards show it, one line, with at least one letter or digit '
115
+ 'A-Z/0-9 (the lane id is <workspace>--<its slug>).'}
116
+ REPO_ARG = {'type': 'string', 'pattern': ID_PATTERN, 'maxLength': 128,
117
+ 'description': 'The repository id (lanes_status shows a lane\'s `repo`) whose .vscode/terminals.json '
118
+ 'gets the lane, when the board has more than one.'}
119
+
120
+
121
+ TOOLS = (
122
+ _tool('lanes_whoami', 'Which lane am I in',
123
+ 'Name the lane this agent runs in: its id, board (workspace), display name, kind (declared in a repository\'s '
124
+ '.vscode/terminals.json, registered with `lanes register`, or an unregistered session) and tmux target. '
125
+ 'Found from $LANE_ID, else from this process\'s tmux pane. `lane` is null outside a lane, with the reason.',
126
+ {}),
127
+ _tool('lanes_list', 'List the lanes in this container',
128
+ 'Every lane in this workspace container — declared, registered and unregistered tmux sessions — with its '
129
+ 'observed state (active, quiet, dead, absent, missing, unknown) and its agent (harness, status such as '
130
+ 'working, needs_input, needs_approval, done, errored). `status.fresh` says whether the host\'s observation '
131
+ 'is live (renewed within 60 s).',
132
+ {'workspace': {'type': 'string', 'pattern': '^[A-Za-z0-9_][A-Za-z0-9_.-]*$', 'maxLength': 128,
133
+ 'description': 'Only the lanes of this board (workspace id).'},
134
+ 'needs_you': {'type': 'boolean',
135
+ 'description': 'true: only lanes whose agent waits for a person (needs_approval, errored, '
136
+ 'needs_input — the boards\' "Needs you" section).'},
137
+ 'agent': {'type': 'string', 'enum': ['any', 'none', 'claude', 'codex'],
138
+ 'description': 'any: lanes with an agent; none: lanes without one; claude / codex: that harness.'}}),
139
+ _tool('lanes_status', 'Show one lane',
140
+ 'One lane in full: the registry facts (board, display name, kind, tmux socket and session, the declaring '
141
+ 'terminals.json and repository), the host\'s observation (state, agent, attached clients, viewers, last '
142
+ 'activity, size, stale), whether its tmux session is alive now (null when tmux did not answer) and its '
143
+ 'lane-signal hook sidecar.',
144
+ {'lane': LANE_ARG}, required=('lane',)),
145
+ _tool('lanes_capture', 'Read a lane\'s screen',
146
+ 'The last `lines` non-blank lines of a tmux lane\'s screen and scrollback, as plain text (wrapped lines '
147
+ 'joined, no escape sequences). Any lane of this container can be read; nothing is typed into it.',
148
+ {'lane': LANE_ARG,
149
+ 'lines': {'type': 'integer', 'minimum': 1, 'maximum': CAPTURE_MAX, 'default': CAPTURE_DEFAULT,
150
+ 'description': f'How many lines, 1–{CAPTURE_MAX} (default {CAPTURE_DEFAULT}).'}},
151
+ required=('lane',)),
152
+ _tool('lanes_register', 'Register a session as a lane',
153
+ 'Declare an unregistered tmux session of this container — the one this agent runs in, or `session` — '
154
+ 'as a lane: one terminal appended to its board\'s .vscode/terminals.json (command `lane open <id>`, the '
155
+ 'exact tmux binding, the canonical create recipe). Nothing is started, stopped or renamed. Answers the '
156
+ 'lane id `lanes sync` will give it. Refused for a session that is already a lane (declared, or adopted '
157
+ 'on the host), a name or id another lane uses, and a board with several repositories unless `repo` '
158
+ 'names one.',
159
+ {'name': NAME_ARG,
160
+ 'session': dict(LANE_ARG, description='The unregistered session, by its lane id as lanes_list shows it '
161
+ '(kind "unregistered"); default: the session this agent runs in.'),
162
+ 'repo': REPO_ARG},
163
+ required=('name',), hints=_writes(False, False)),
164
+ _tool('lanes_unregister', 'Unregister a declared lane',
165
+ 'Remove a declared lane\'s terminal from its .vscode/terminals.json (its `terminals[]` item and its '
166
+ '`lanes.terminals` entry). The tmux session keeps running and shows again as an unregistered session; '
167
+ 'nothing is killed. Refused for the last terminal of a file, an adopted session (kept on the host) and a '
168
+ 'lane whose declaration is not in a file this container holds.',
169
+ {'lane': LANE_ARG}, required=('lane',), hints=_writes(True, True)),
170
+ _tool('lanes_rename', 'Rename a lane',
171
+ 'Set a declared lane\'s display name: `lanes.terminals[<terminal>].displayName` in its terminals.json. '
172
+ 'The lane id never changes. The display name equal to the lane\'s title removes it. A name another lane '
173
+ 'of the board shows is refused (the board would drop it); a display name the host sets for the lane '
174
+ '(its workspace manifest) still wins and is reported.',
175
+ {'lane': LANE_ARG, 'display_name': dict(NAME_ARG, description='The new display name, one line.')},
176
+ required=('lane', 'display_name'), hints=_writes(False, True)),
177
+ _tool('lanes_new', 'Start a new lane',
178
+ 'Declare a new lane in a board\'s .vscode/terminals.json — a tmux session on the board\'s shell socket '
179
+ '`lanes-shells-<workspace>` with the canonical create recipe — and start that session (detached, '
180
+ '100x30) with the lane\'s identity (LANE_ID, LANE_WS, the Throttle trace). The workspace defaults to this '
181
+ 'agent\'s own board, the folder to the repository\'s.',
182
+ {'name': NAME_ARG,
183
+ 'cwd': {'type': 'string', 'minLength': 1, 'maxLength': PATH_MAX,
184
+ 'description': 'The working directory: an absolute path under /home/coder (letters, digits, '
185
+ '_ . / - only); default: the repository folder.'},
186
+ 'command': {'type': 'string', 'minLength': 1, 'maxLength': COMMAND_MAX,
187
+ 'description': 'The shell command the lane runs, e.g. "claude"; default: "bash -l".'},
188
+ 'workspace': {'type': 'string', 'pattern': ID_PATTERN, 'maxLength': 128,
189
+ 'description': 'The board (workspace id); default: this agent\'s own.'},
190
+ 'repo': REPO_ARG},
191
+ required=('name',), hints=_writes(False, False)),
192
+ _tool('lanes_send', 'Type into a lane',
193
+ 'Type `text` literally into a lane of this container (its tmux session\'s active pane), then Enter unless '
194
+ '`enter` is false. Printable text only: no control characters, so no key a person would use to '
195
+ 'interrupt. Refused while the lane\'s agent is working or waiting for an approval, unless `force` is '
196
+ 'true.',
197
+ {'lane': LANE_ARG,
198
+ 'text': {'type': 'string', 'minLength': 1, 'maxLength': SEND_MAX,
199
+ 'description': f'The text to type: printable, one line, at most {SEND_MAX} characters.'},
200
+ 'enter': {'type': 'boolean', 'default': True, 'description': 'Press Enter after the text (default true).'},
201
+ 'force': {'type': 'boolean', 'default': False,
202
+ 'description': 'Type even while the lane\'s agent is working or waiting for an approval.'}},
203
+ required=('lane', 'text'), hints=_writes(True, False)),
204
+ _tool('lanes_signal', 'Report my own status',
205
+ 'Report this agent\'s own status on the Lanes boards through lane-signal (the hook sidecar the host reads), '
206
+ 'with an optional one-line note shown as the lane\'s task line. Only this agent\'s own lane. The report '
207
+ 'stands until your next tool call, a new prompt, a permission prompt, a failed turn or the session\'s end; '
208
+ 'the end of your turn keeps it (a working report ends there). So report needs_input, done or errored as '
209
+ 'the last thing you do before you end your turn.',
210
+ {'status': {'type': 'string', 'enum': list(C.STATUSES),
211
+ 'description': 'working, needs_input, needs_approval, done, errored, shell or idle.'},
212
+ 'note': {'type': 'string', 'maxLength': NOTE_MAX,
213
+ 'description': f'What you are doing, one line of at most {NOTE_MAX} characters; "" clears it. '
214
+ 'Kept while the report stands or its status repeats.'}},
215
+ required=('status',), hints=_writes(False, True)),
216
+ )
217
+ TOOL_BY_NAME = {tool['name']: tool for tool in TOOLS}
218
+ WRITE_TOOLS = frozenset(tool['name'] for tool in TOOLS if not tool['annotations']['readOnlyHint'])
219
+
220
+
221
+ class RpcError(Exception):
222
+ def __init__(self, code, message):
223
+ super().__init__(message)
224
+ self.code, self.message = code, message
225
+
226
+
227
+ class ToolError(Exception):
228
+ """A tool that cannot do its job: answered as `isError: true` content, never as a protocol error."""
229
+
230
+
231
+ # What a person can do instead, per refusal code (a raise site may say it more precisely).
232
+ HUMAN = {
233
+ 'invalid_arguments': 'Correct the arguments: tools/list gives this tool\'s input schema.',
234
+ 'contract_outdated': 'A person runs `lanes install-container <container>` on the host.',
235
+ 'not_in_lane': 'Run it from inside a lane, or name the lane or session (lanes_list shows them).',
236
+ 'not_in_container': 'lanes_list shows the lanes of this container; a lane of another container is out of reach '
237
+ 'from here.',
238
+ 'adopted_lane': 'An adopted session is kept on the host (registered-sessions.json): a person renames it there '
239
+ '(`lanes rename <id> <name>`, or R in the sidebar) or removes it there.',
240
+ 'already_declared': 'It is already a lane: lanes_rename names it, lanes_unregister removes it.',
241
+ 'unregistered': 'It is not a lane yet: lanes_register declares it (a person can also close it on the board).',
242
+ 'no_source': 'A person adds a repository with a .vscode/terminals.json to that board on the host (its '
243
+ 'workspaces.<board>.json), or declares the terminal by hand.',
244
+ 'ambiguous_source': 'Pass `repo`: one of the repositories named in `repos`.',
245
+ 'ambiguous_workspace': 'Pass `workspace`: one of the boards named in `workspaces`.',
246
+ 'source_missing': 'A person checks the repository on the host (`lanes doctor` names a board whose terminals.json '
247
+ 'cannot be read).',
248
+ 'source_invalid': 'A person fixes the file by hand (strict JSON: no comments, trailing commas or repeated keys), '
249
+ 'then try again.',
250
+ 'source_unreadable': 'A person checks the file: it must be a regular UTF-8 file this account can read.',
251
+ 'source_link': 'The file or a directory above it is a symbolic link: a person edits the file it points at.',
252
+ 'edit_conflict': 'Another writer changed the file twice meanwhile: try again.',
253
+ 'edit_refused': 'The edit would have changed more than its own entry: a person edits the file by hand.',
254
+ 'no_lanes_block': 'A person runs `lanes cursor-sync --write` on the host (it adds the lanes block), then try '
255
+ 'again.',
256
+ 'not_declared_here': 'Its declaration is not in a file this container holds (the host may keep its binding in the '
257
+ 'board\'s workspace manifest): a person changes it on the host.',
258
+ 'unknown_prefix': 'A person runs `lanes sync` on the host, then try again.',
259
+ 'last_terminal': 'A terminals.json keeps at least one terminal: a person edits the file, or the board, on the '
260
+ 'host.',
261
+ 'duplicate_name': 'Pick another name: lanes_list shows the names in use.',
262
+ 'duplicate_binding': 'That tmux session is declared already: lanes_list shows it, or `lanes sync` will.',
263
+ 'slug_collision': 'Pick a name whose letters and digits differ from that lane\'s: the id is made of them.',
264
+ 'invalid_name': 'A name is one line of at most 128 characters with at least one letter or digit A-Z/0-9.',
265
+ 'invalid_session': 'The tmux session name holds characters a lane cannot be declared with: a person renames the '
266
+ 'session (letters, digits, _ . - and spaces) first.',
267
+ 'invalid_cwd': 'Pass an existing directory under /home/coder (letters, digits, _ . / - only).',
268
+ 'invalid_command': 'Pass one line of printable text.',
269
+ 'invalid_text': 'Send printable text only (no newline, tab or escape): interrupting a lane or pressing other keys '
270
+ 'is a person\'s call.',
271
+ 'invalid_note': 'A note is one line of printable text.',
272
+ 'agent_working': 'Wait until lanes_status shows its agent is no longer working, or pass force: true. Interrupting '
273
+ 'it is a person\'s call.',
274
+ 'agent_needs_approval': 'A person answers its approval prompt. Pass force: true only to type an answer you were '
275
+ 'asked to give.',
276
+ 'not_tmux': 'A zmx lane takes `~/.lz/bin/lane send <id> <text>`.',
277
+ 'session_gone': 'The tmux session is not running: lanes_list shows the live sessions.',
278
+ 'session_exists': 'Pick another name: a tmux session with that name already runs on that socket.',
279
+ 'tmux_unanswered': 'tmux did not answer in time: try again in a moment.',
280
+ 'tmux_failed': 'lanes_status says whether the lane is running.',
281
+ 'signal_not_written': 'A person checks the lane-signal install (`lanes install-container <container>` on the '
282
+ 'host).',
283
+ 'refused': 'A person takes it from here.',
284
+ }
285
+
286
+
287
+ class Refusal(ToolError):
288
+ """A write tool's refusal: `isError: true` with {refused: <code>, why, human, …extra}. Nothing was changed
289
+ unless `extra` says so (`changed`)."""
290
+
291
+ def __init__(self, code, why, human=None, **extra):
292
+ super().__init__(why)
293
+ self.code, self.why = code, why
294
+ self.human = human or HUMAN.get(code) or HUMAN['refused']
295
+ self.extra = extra
296
+
297
+ @classmethod
298
+ def of(cls, error):
299
+ """A lane_core.LaneError (or a plain ToolError) as a refusal, by its code."""
300
+ if isinstance(error, Refusal):
301
+ return error
302
+ return cls(getattr(error, 'code', None) or 'refused', str(error))
303
+
304
+ def payload(self):
305
+ return {'refused': self.code, 'why': self.why, 'human': self.human, **self.extra}
306
+
307
+
308
+ def refuse(code, why, human=None, **extra):
309
+ raise Refusal(code, why, human, **extra)
310
+
311
+
312
+ class Log:
313
+ """Append-only lines in ~/.lz/s/logs/lane-mcp.log; a log that cannot be written is silence, never a failure."""
314
+
315
+ def __init__(self, path):
316
+ self.path = Path(path) if path else None
317
+ if not self.path:
318
+ return
319
+ try:
320
+ self.path.parent.mkdir(parents=True, exist_ok=True)
321
+ if self.path.stat().st_size > LOG_MAX:
322
+ os.replace(str(self.path), str(self.path) + '.1')
323
+ except FileNotFoundError:
324
+ pass
325
+ except OSError:
326
+ self.path = None
327
+
328
+ def write(self, text, trace=None):
329
+ """One line; control characters in `text` (it may quote a client's method name) are escaped, so a request
330
+ can never forge a log line. `trace`, our own traceback, follows it as is."""
331
+ if not self.path:
332
+ return
333
+ text = re.sub(r'[\x00-\x1f\x7f]', lambda match: f'\\x{ord(match.group()):02x}', text)
334
+ try:
335
+ with open(self.path, 'a', encoding='utf-8', errors='replace') as handle:
336
+ handle.write(f'{time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} [{os.getpid()}] {text}\n'
337
+ + (trace or ''))
338
+ except OSError:
339
+ pass
340
+
341
+
342
+ def validate(schema, arguments):
343
+ """The problems of `arguments` against one of our input schemas (the subset they use), as sentences."""
344
+ problems, properties = [], schema.get('properties', {})
345
+ for key in schema.get('required', []):
346
+ if key not in arguments:
347
+ problems.append(f'missing required argument "{key}"')
348
+ for key, value in arguments.items():
349
+ spec = properties.get(key)
350
+ if spec is None:
351
+ problems.append(f'unknown argument "{key}" (this tool takes: {", ".join(properties) or "nothing"})')
352
+ elif spec['type'] == 'string':
353
+ if not isinstance(value, str):
354
+ problems.append(f'"{key}" must be a string')
355
+ elif 'enum' in spec and value not in spec['enum']:
356
+ problems.append(f'"{key}" must be one of {", ".join(spec["enum"])}')
357
+ elif 'pattern' in spec and (len(value) > spec['maxLength'] or not C.LANE_RE.fullmatch(value)):
358
+ problems.append(f'"{key}" is not an id (letters, digits, _ . -; at most {spec["maxLength"]})')
359
+ elif len(value) < spec.get('minLength', 0):
360
+ problems.append(f'"{key}" must not be empty')
361
+ elif len(value) > spec.get('maxLength', len(value)):
362
+ problems.append(f'"{key}" is longer than {spec["maxLength"]} characters')
363
+ elif spec['type'] == 'boolean' and not isinstance(value, bool):
364
+ problems.append(f'"{key}" must be true or false')
365
+ elif spec['type'] == 'integer':
366
+ if not isinstance(value, int) or isinstance(value, bool):
367
+ problems.append(f'"{key}" must be an integer')
368
+ elif not spec['minimum'] <= value <= spec['maximum']:
369
+ problems.append(f'"{key}" must be between {spec["minimum"]} and {spec["maximum"]}')
370
+ return problems
371
+
372
+
373
+ def freshness(status):
374
+ """The observation's age as a tool reports it."""
375
+ if not status['available']:
376
+ return {'available': False, 'why': status['why']}
377
+ return {'available': True, 'at': status['at'], 'age_s': status['age'], 'fresh': status['fresh']}
378
+
379
+
380
+ def agent_summary(agent):
381
+ """The agent of a lanes_list row: harness, status, task name and since — what a list needs."""
382
+ if not isinstance(agent, dict) or not agent.get('status'):
383
+ return None
384
+ return {key: agent[key] for key in ('harness', 'status', 'name', 'since') if agent.get(key) is not None}
385
+
386
+
387
+ OBSERVED = ('attached', 'viewers', 'activity', 'size', 'stale')
388
+
389
+
390
+ class Server:
391
+ def __init__(self, root, environ=None, log=None, proc=Path('/proc'), home=None, signal=None):
392
+ self.root, self.proc = Path(root), proc
393
+ # The container's /home/coder as this process sees it: itself in a container (root ~/.lz), the volume a test
394
+ # builds otherwise. Every container path a write tool reads or writes goes through local().
395
+ self.home = Path(home) if home else self.root.parent
396
+ self.signal = Path(signal) if signal else Path(__file__).resolve().parent / 'lane-signal'
397
+ self.environ = os.environ if environ is None else environ
398
+ self.log = log or Log(None)
399
+ self.protocol = None
400
+ self.client = ''
401
+ version = C.read_regular(self.root / 'VERSION', 64)
402
+ self.version = (version or b'').decode('utf-8', 'replace').strip() or 'unknown'
403
+
404
+ # ---------------------------------------------------------------------------------------------- framing
405
+ def serve(self, rfile, wfile):
406
+ """Answer every line of `rfile` on `wfile` until EOF -> exit code 0."""
407
+ self.log.write(f'start lane-mcp {self.version} wire {WIRE} root {self.root}')
408
+ try:
409
+ while True:
410
+ raw = rfile.readline(LINE_MAX + 1)
411
+ if not raw:
412
+ break
413
+ if len(raw) > LINE_MAX and not raw.endswith(b'\n'):
414
+ while raw and not raw.endswith(b'\n'): # the rest of the oversized line
415
+ raw = rfile.readline(LINE_MAX)
416
+ response = self.error(None, PARSE_ERROR, f'Parse error: a message longer than {LINE_MAX} bytes')
417
+ else:
418
+ response = self.handle_line(raw)
419
+ if response is not None:
420
+ self.send(wfile, response)
421
+ except BrokenPipeError:
422
+ self.log.write('stdout closed: exit')
423
+ return 0
424
+ self.log.write('stdin closed: exit')
425
+ return 0
426
+
427
+ @staticmethod
428
+ def send(wfile, message):
429
+ try:
430
+ data = json.dumps(message, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
431
+ except UnicodeEncodeError: # a lone surrogate echoed from the request: escape everything instead
432
+ data = json.dumps(message, separators=(',', ':')).encode('ascii')
433
+ wfile.write(data + b'\n')
434
+ wfile.flush()
435
+
436
+ @staticmethod
437
+ def error(request_id, code, message):
438
+ return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': code, 'message': message}}
439
+
440
+ def handle_line(self, raw):
441
+ """One frame -> the response to write, or None (a notification, a client response, a blank line)."""
442
+ raw = raw.strip()
443
+ if not raw:
444
+ return None
445
+ try:
446
+ message = json.loads(raw.decode('utf-8'))
447
+ except (UnicodeDecodeError, ValueError):
448
+ self.log.write('parse error')
449
+ return self.error(None, PARSE_ERROR, 'Parse error: a line that is not one JSON value')
450
+ if isinstance(message, list):
451
+ return self.error(None, INVALID_REQUEST, 'Invalid Request: batches are not supported')
452
+ request_id = message.get('id') if isinstance(message, dict) else None
453
+ if isinstance(request_id, bool) or not isinstance(request_id, (str, int, type(None))):
454
+ request_id = None
455
+ if not isinstance(message, dict) or message.get('jsonrpc') != '2.0':
456
+ return self.error(request_id, INVALID_REQUEST, 'Invalid Request: not a JSON-RPC 2.0 message')
457
+ if 'method' not in message:
458
+ return None # a response to a request we never send
459
+ method = message['method']
460
+ if not isinstance(method, str):
461
+ return self.error(request_id, INVALID_REQUEST, 'Invalid Request: method must be a string')
462
+ params = message.get('params')
463
+ if 'id' not in message:
464
+ self.notification(method)
465
+ return None
466
+ if message['id'] is None or request_id is None:
467
+ return self.error(None, INVALID_REQUEST, 'Invalid Request: id must be a string or an integer')
468
+ if params is not None and not isinstance(params, dict):
469
+ return self.error(request_id, INVALID_PARAMS, 'Invalid params: params must be an object')
470
+ started = time.monotonic()
471
+ try:
472
+ result = self.request(method, params or {})
473
+ except RpcError as error:
474
+ self.log.write(f'{method} id={request_id!r} error {error.code} {error.message}')
475
+ return self.error(request_id, error.code, error.message)
476
+ except Exception: # a defect here must not end the session: the agent gets an error, the log the trace
477
+ self.log.write(f'{method} id={request_id!r} internal error', trace=traceback.format_exc())
478
+ return self.error(request_id, INTERNAL_ERROR, f'Internal error (see {LOG_HINT})')
479
+ if method != 'tools/call':
480
+ self.log.write(f'{method} id={request_id!r} ok {time.monotonic() - started:.3f}s')
481
+ return {'jsonrpc': '2.0', 'id': request_id, 'result': result}
482
+
483
+ def notification(self, method):
484
+ if method == 'notifications/initialized':
485
+ self.log.write('initialized')
486
+ elif method != 'notifications/cancelled':
487
+ self.log.write(f'notification {method} ignored')
488
+
489
+ def request(self, method, params):
490
+ if method == 'initialize':
491
+ return self.initialize(params)
492
+ if method == 'ping':
493
+ return {}
494
+ if self.protocol is None:
495
+ raise RpcError(NOT_INITIALIZED, 'Server not initialized: send initialize first')
496
+ if method == 'tools/list':
497
+ return {'tools': json.loads(json.dumps(TOOLS))}
498
+ if method == 'tools/call':
499
+ return self.call(params)
500
+ raise RpcError(METHOD_NOT_FOUND, f'Method not found: {method}')
501
+
502
+ def initialize(self, params):
503
+ requested = params.get('protocolVersion')
504
+ self.protocol = requested if requested in PROTOCOLS else PROTOCOLS[0]
505
+ client = params.get('clientInfo') if isinstance(params.get('clientInfo'), dict) else {}
506
+ self.client = str(client.get('name') or '')[:64]
507
+ self.log.write(f'initialize client={str(client.get("name"))[:64]!r} {str(client.get("version"))[:32]!r} '
508
+ f'asked={str(requested)[:32]!r} protocol={self.protocol}')
509
+ return {'protocolVersion': self.protocol, 'capabilities': {'tools': {'listChanged': False}},
510
+ 'serverInfo': {'name': 'lanes', 'version': self.version}, 'instructions': INSTRUCTIONS}
511
+
512
+ # ---------------------------------------------------------------------------------------------- tools
513
+ def call(self, params):
514
+ name, arguments = params.get('name'), params.get('arguments')
515
+ if not isinstance(name, str) or name not in TOOL_BY_NAME:
516
+ raise RpcError(INVALID_PARAMS, f'Unknown tool: {name!r} (tools/list names them)')
517
+ started, lane, code = time.monotonic(), None, None
518
+ writes = name in WRITE_TOOLS
519
+ try:
520
+ if arguments is None:
521
+ arguments = {}
522
+ if not isinstance(arguments, dict):
523
+ raise ToolError('arguments must be an object')
524
+ lane = arguments.get('lane') if C.LANE_RE.fullmatch(str(arguments.get('lane') or '')) else None
525
+ problems = validate(TOOL_BY_NAME[name]['inputSchema'], arguments)
526
+ if problems:
527
+ raise ToolError('; '.join(problems))
528
+ payload, error = getattr(self, 'tool_' + name[len('lanes_'):])(arguments), False
529
+ if writes and isinstance(payload, dict):
530
+ lane = payload.get('lane') or payload.get('lane_id') or lane
531
+ except (ToolError, C.LaneError) as refusal:
532
+ if writes: # a write tool always answers JSON: a refusal is {refused, why, human}
533
+ if isinstance(refusal, C.LaneError):
534
+ refusal = Refusal.of(refusal)
535
+ elif not isinstance(refusal, Refusal): # a plain ToolError: the arguments, outside the schema
536
+ refusal = Refusal('invalid_arguments', str(refusal))
537
+ code = refusal.code
538
+ payload = refusal.payload()
539
+ else:
540
+ payload = str(refusal)
541
+ error = True
542
+ except Exception:
543
+ self.log.write(f'tools/call {name} internal error', trace=traceback.format_exc())
544
+ payload, error = f'internal error in {name} (see {LOG_HINT})', True
545
+ if writes:
546
+ code, payload = 'internal_error', {'refused': 'internal_error', 'why': payload,
547
+ 'human': f'A person reads {LOG_HINT}.'}
548
+ outcome = (f'isError refused={code}' if code else 'isError') if error else 'ok'
549
+ self.log.write(f'tools/call {name}' + (f' lane={lane}' if lane else '')
550
+ + f' {outcome} {time.monotonic() - started:.3f}s')
551
+ text = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False, indent=1)
552
+ return {'content': [{'type': 'text', 'text': text}], 'isError': error}
553
+
554
+ def tool_whoami(self, _arguments):
555
+ found = C.whoami(self.root, self.environ)
556
+ out = {'lane': found['lane'], 'found_by': found['found_by'], 'kind': found['kind']}
557
+ entry, session = found.get('entry'), found.get('session')
558
+ if entry:
559
+ out.update(self.facts(entry))
560
+ elif session:
561
+ out.update({'workspace': session.get('workspace'), 'name': session.get('name'), 'holder': 'tmux',
562
+ 'tmux': session.get('tmux'), 'boards': session.get('boards')})
563
+ if found.get('tmux'):
564
+ out['tmux'] = found['tmux']
565
+ if found.get('why'):
566
+ out['why'] = found['why']
567
+ return out
568
+
569
+ @staticmethod
570
+ def facts(entry):
571
+ """The registry facts of a lane (DM-LANES-REGISTRY's in-container entry), in a fixed order."""
572
+ return {'workspace': entry.get('ws'), 'name': entry.get('name'), 'holder': entry.get('holder'),
573
+ 'tmux': entry.get('tmux'), 'container': entry.get('container'), 'source': entry.get('source'),
574
+ 'repo': entry.get('repo'), 'registry_name': entry.get('registry_name'), 'folder': entry.get('folder')}
575
+
576
+ def lanes(self):
577
+ """(registry lanes, the container's status) — the two projections every tool reads."""
578
+ return C.registry_lanes(self.root), C.container_status(self.root)
579
+
580
+ def tool_list(self, arguments):
581
+ lanes, status = self.lanes()
582
+ rows = []
583
+ for lane_id, entry in lanes.items():
584
+ observed = status['lanes'].get(lane_id) or {}
585
+ rows.append({'id': lane_id, 'name': entry.get('name') or lane_id, 'workspace': entry.get('ws'),
586
+ 'kind': C.lane_kind(entry), 'holder': entry.get('holder'),
587
+ 'state': observed.get('state', 'unknown'), 'agent': agent_summary(observed.get('agent')),
588
+ **{key: observed[key] for key in OBSERVED if key in observed}, '_boards': [entry.get('ws')]})
589
+ for ghost in status['unregistered']:
590
+ observed = status['lanes'].get(ghost['id']) or {}
591
+ rows.append({'id': ghost['id'], 'name': ghost.get('name') or ghost['id'], 'workspace': ghost.get('workspace'),
592
+ 'kind': 'unregistered', 'holder': 'tmux', 'state': observed.get('state', 'unknown'),
593
+ 'agent': agent_summary(observed.get('agent')),
594
+ **{key: observed[key] for key in OBSERVED if key in observed},
595
+ '_boards': list(ghost.get('boards') or [ghost.get('workspace')])})
596
+ workspaces = sorted({board for row in rows for board in row['_boards'] if board})
597
+ want_ws, want_agent = arguments.get('workspace'), arguments.get('agent')
598
+ picked = []
599
+ for row in rows:
600
+ boards = row.pop('_boards')
601
+ agent = row['agent']
602
+ if want_ws and want_ws not in boards:
603
+ continue
604
+ if arguments.get('needs_you') and (not agent or agent.get('status') not in C.NEEDS_YOU):
605
+ continue
606
+ if want_agent == 'any' and not agent or want_agent == 'none' and agent:
607
+ continue
608
+ if want_agent in ('claude', 'codex') and (not agent or agent.get('harness') != want_agent):
609
+ continue
610
+ picked.append(row)
611
+ container = next((entry.get('container') for entry in lanes.values() if entry.get('container')), None)
612
+ return {'container': container, 'status': freshness(status), 'workspaces': workspaces, 'count': len(picked),
613
+ 'lanes': picked}
614
+
615
+ def find(self, lane):
616
+ """(registry entry or None, unregistered row or None, status) of a lane id; ToolError when this container
617
+ holds no such lane."""
618
+ lanes, status = self.lanes()
619
+ entry = lanes.get(lane)
620
+ ghost = None if entry else next((row for row in status['unregistered'] if row['id'] == lane), None)
621
+ if not entry and not ghost:
622
+ raise ToolError(f'no lane {lane!r} in this container — lanes_list shows the lanes it holds')
623
+ return entry, ghost, status
624
+
625
+ def tool_status(self, arguments):
626
+ lane = arguments['lane']
627
+ entry, ghost, status = self.find(lane)
628
+ observed = status['lanes'].get(lane) or {}
629
+ if entry:
630
+ out = {'id': lane, 'kind': C.lane_kind(entry), **self.facts(entry)}
631
+ target = C.tmux_target(entry)
632
+ else:
633
+ tmux = ghost.get('tmux') or {}
634
+ out = {'id': lane, 'kind': 'unregistered', 'workspace': ghost.get('workspace'), 'name': ghost.get('name'),
635
+ 'holder': 'tmux', 'tmux': tmux, 'boards': ghost.get('boards'), 'identity': ghost.get('identity')}
636
+ target = (tmux['socket'], tmux['session']) if tmux.get('socket') and tmux.get('session') else None
637
+ out['state'] = observed.get('state', 'unknown')
638
+ out['agent'] = observed.get('agent') if isinstance(observed.get('agent'), dict) else None
639
+ out.update({key: observed[key] for key in OBSERVED if key in observed})
640
+ alive = None
641
+ if target:
642
+ code = C.run_tmux(C.tmux_argv(target[0], 'has-session', '-t', f'={target[1]}')).returncode
643
+ alive = None if code in (124, 127) else code == 0
644
+ out['alive'] = alive
645
+ out['hook'] = C.hook_agent(self.root / 'state' / 'hooks', lane, self.proc) or None
646
+ out['status'] = freshness(status)
647
+ return out
648
+
649
+ def tool_capture(self, arguments):
650
+ lane, lines = arguments['lane'], arguments.get('lines', CAPTURE_DEFAULT)
651
+ entry, ghost, _status = self.find(lane)
652
+ if entry:
653
+ target = C.tmux_target(entry)
654
+ if not target:
655
+ raise ToolError(f'{lane} is held by {entry.get("holder")}, not tmux — read it with '
656
+ f'`~/.lz/bin/lane capture {lane}`')
657
+ else:
658
+ tmux = ghost.get('tmux') or {}
659
+ if not (tmux.get('socket') and tmux.get('session')):
660
+ raise ToolError(f'{lane} has no tmux target in ~/.lz/status.json')
661
+ target = (tmux['socket'], tmux['session'])
662
+ result = C.run_tmux(C.capture_argv(target[0], target[1], lines))
663
+ if result.returncode != 0:
664
+ why = (result.stderr or '').strip().splitlines()
665
+ raise ToolError(f'{lane}: {why[0] if why else f"tmux exit {result.returncode}"} — lanes_status says '
666
+ f'whether it is running')
667
+ text = C.last_lines(result.stdout, lines)
668
+ if len(text) > TEXT_MAX:
669
+ text = text[-TEXT_MAX:]
670
+ text = '[… earlier output omitted]\n' + text[text.find('\n') + 1:]
671
+ return text
672
+
673
+ # ---------------------------------------------------------------------------------------------- write helpers
674
+ def local(self, path):
675
+ """A container path under /home/coder as this process reaches it (self.home + the rest); ValueError for any
676
+ other path."""
677
+ text = str(path)
678
+ if not (text + '/').startswith(C.CONTAINER_HOME + '/') or '..' in text.split('/'):
679
+ raise ValueError(f'{text} is not under {C.CONTAINER_HOME}')
680
+ rest = text[len(C.CONTAINER_HOME) + 1:]
681
+ return self.home / rest if rest else self.home
682
+
683
+ def find_write(self, lane):
684
+ """(registry entry or None, unregistered row or None, status) of a lane of THIS container; refused
685
+ `not_in_container` otherwise — every write reaches this container's lanes only."""
686
+ lanes, status = self.lanes()
687
+ entry = lanes.get(lane)
688
+ ghost = None if entry else next((row for row in status['unregistered'] if row['id'] == lane), None)
689
+ if not entry and not ghost:
690
+ refuse('not_in_container', f'no lane {lane!r} in this container')
691
+ return entry, ghost, status
692
+
693
+ @staticmethod
694
+ def declared(lanes):
695
+ """{lane id: entry} of the lanes a terminals.json declares (an adopted session names none)."""
696
+ return {key: entry for key, entry in lanes.items()
697
+ if entry.get('source') and not entry.get('registered_session')}
698
+
699
+ @staticmethod
700
+ def refuse_lane(lane_id, entry):
701
+ """A session that is a lane already: adopted on the host, or declared."""
702
+ if entry.get('registered_session'):
703
+ refuse('adopted_lane', f'{lane_id} was adopted on the host (registered-sessions.json): an agent never edits '
704
+ 'an adopted session', lane=lane_id)
705
+ refuse('already_declared', f'{lane_id} is declared already ({entry.get("source") or "a terminals.json"})',
706
+ lane=lane_id)
707
+
708
+ def declared_target(self, lane, entry):
709
+ """(path, doc, terminal name) of a declared lane's terminal in its terminals.json, or a refusal."""
710
+ if entry.get('registered_session'):
711
+ self.refuse_lane(lane, entry)
712
+ if not entry.get('source'):
713
+ refuse('not_declared_here', f'{lane} names no terminals.json')
714
+ path, _text, doc = self.read_source(entry['source'])
715
+ name = C.declared_name(doc, entry)
716
+ if name is None:
717
+ refuse('not_declared_here', f'{entry["source"]} declares no terminal this container can match to {lane} '
718
+ '(by its tmux binding or its name)')
719
+ return path, doc, name
720
+
721
+ def read_source(self, source):
722
+ """(local path, text, parsed document) of a terminals.json named by its container path."""
723
+ try:
724
+ path = self.local(source)
725
+ except ValueError as error:
726
+ refuse('not_declared_here', str(error))
727
+ if not os.path.lexists(str(path)):
728
+ refuse('source_missing', f'{source} does not exist in this container')
729
+ _ident, text = C._snapshot(path)
730
+ return path, text, C.strict_json(text, source)
731
+
732
+ @staticmethod
733
+ def edit_source(path, source, transform):
734
+ """lane_core.edit_file with a terminals.json transform -> (before, after). The file is the repository's own
735
+ (git is its history), so no backup is kept; its mode is."""
736
+ def guarded(text):
737
+ if text is None:
738
+ raise C.LaneError(f'{source} disappeared while it was being edited', 'source_missing')
739
+ return transform(text)
740
+ before, after, _backup = C.edit_file(path, guarded)
741
+ return before, after
742
+
743
+ def plan(self, lanes, status, boards, repo, name, prefer):
744
+ """Where a new declaration named `name` goes and the id `lanes sync` will give it: the one terminals.json of
745
+ `boards` (narrowed by `repo`) in this container, the prefix each board that reads that file puts before its
746
+ names, and per board the predicted id `<ws>--<slug(prefix + name)>`. Refuses a name or id the board already
747
+ uses: a row reading the same (the host would refuse the board, or drop another lane's display name) or an id
748
+ `build_registry` would have to suffix (`-2`: the new lane could take an existing lane's id)."""
749
+ declared = self.declared(lanes)
750
+ on_boards = [entry for entry in declared.values() if entry.get('ws') in boards]
751
+ candidates = [entry for entry in on_boards if not repo or entry.get('repo') == repo]
752
+ sources = sorted({entry['source'] for entry in candidates})
753
+ repos = sorted({str(entry.get('repo')) for entry in on_boards})
754
+ where = ', '.join(sorted(board for board in boards if board)) or 'none'
755
+ if not sources:
756
+ refuse('no_source', f'no repository {"named " + repr(repo) + " " if repo else ""}declares lanes of board '
757
+ f'{where} in this container', repos=repos)
758
+ if len(sources) > 1:
759
+ refuse('ambiguous_source', f'board {where} has {len(sources)} repositories in this container '
760
+ f'({", ".join(repos)}): which terminals.json gets the lane?', repos=repos)
761
+ source = sources[0]
762
+ path, _text, doc = self.read_source(source)
763
+ by_ws = {}
764
+ for entry in declared.values():
765
+ if entry['source'] == source:
766
+ by_ws.setdefault(entry.get('ws'), []).append(entry)
767
+ taken = set(lanes) | {row['id'] for row in status['unregistered']}
768
+ ids = {}
769
+ for ws, entries in sorted(by_ws.items()):
770
+ prefix = C.source_prefix(doc, entries)
771
+ if prefix is None:
772
+ refuse('unknown_prefix', f'cannot tell which prefix board {ws} puts before the names in {source}')
773
+ title = prefix + name
774
+ key = C.shown_key(title)
775
+ for other_id, other in lanes.items():
776
+ if other.get('ws') == ws and key in {C.shown_key(str(other.get(field) or ''))
777
+ for field in ('name', 'registry_name')}:
778
+ refuse('duplicate_name', f'{title!r} is the name of {other_id} on board {ws}', lane=other_id)
779
+ lane_id = f'{ws}--{C.slug(title)}'
780
+ twins = re.compile(re.escape(lane_id) + r'(?:-[0-9]+)?')
781
+ clash = sorted(other for other in taken if twins.fullmatch(other)) + sorted(
782
+ other for other, entry in declared.items() if entry.get('ws') == ws and entry.get('registry_name')
783
+ and f'{ws}--{C.slug(entry["registry_name"])}' == lane_id)
784
+ if clash:
785
+ refuse('slug_collision', f'{title!r} makes the lane id {lane_id}, which {clash[0]} holds or is named '
786
+ 'from', lane_id=lane_id, lane=clash[0])
787
+ ids[ws] = lane_id
788
+ ws = prefer if prefer in ids else sorted(ids)[0]
789
+ return {'source': source, 'path': path, 'ws': ws, 'lane_id': ids[ws], 'ids': ids,
790
+ 'repo': next((entry.get('repo') for entry in by_ws[ws] if entry.get('repo')), None),
791
+ 'folder': next((entry.get('folder') for entry in by_ws[ws] if entry.get('folder')), None)
792
+ or C.CONTAINER_HOME}
793
+
794
+ @staticmethod
795
+ def session_state(socket, session):
796
+ """has-session (with -N) -> True (running), False (not running, or no server), or a refusal when tmux did
797
+ not answer."""
798
+ result = C.run_tmux(C.tmux_argv(socket, 'has-session', '-t', f'={session}'))
799
+ if result.returncode in (124, 127):
800
+ refuse('tmux_unanswered', (result.stderr or '').strip() or f'tmux exit {result.returncode}')
801
+ return result.returncode == 0
802
+
803
+ def check_cwd(self, cwd):
804
+ """An existing directory under /home/coder, spelled as a lane folder may be -> its normalized path."""
805
+ if not C.SAFE_PATH.match(cwd) or '..' in cwd.split('/'):
806
+ refuse('invalid_cwd', f'{cwd!r}: an absolute path of letters, digits, _ . / - only')
807
+ cwd = os.path.normpath(cwd)
808
+ if not (cwd + '/').startswith(C.CONTAINER_HOME + '/'):
809
+ refuse('invalid_cwd', f'{cwd} is outside {C.CONTAINER_HOME}')
810
+ local = self.local(cwd)
811
+ if not local.is_dir():
812
+ refuse('invalid_cwd', f'{cwd} is not an existing directory')
813
+ real, home = os.path.realpath(str(local)), os.path.realpath(str(self.home))
814
+ if real != home and not real.startswith(home + os.sep):
815
+ refuse('invalid_cwd', f'{cwd} leads outside {C.CONTAINER_HOME} (a symbolic link)')
816
+ return cwd
817
+
818
+ @staticmethod
819
+ def unprintable(text):
820
+ """The control characters (and lone surrogates) of `text`, as U+XXXX."""
821
+ return sorted({f'U+{ord(ch):04X}' for ch in text if unicodedata.category(ch) in ('Cc', 'Cs')})
822
+
823
+ # ---------------------------------------------------------------------------------------------- write tools
824
+ def tool_register(self, arguments):
825
+ name = arguments['name']
826
+ problem = C.lane_name_problem(name)
827
+ if problem:
828
+ refuse('invalid_name', f'{name!r}: {problem}')
829
+ lanes, status = self.lanes()
830
+ if arguments.get('session'):
831
+ wanted = arguments['session']
832
+ ghost = next((row for row in status['unregistered'] if row['id'] == wanted), None)
833
+ if ghost is None:
834
+ if wanted in lanes:
835
+ self.refuse_lane(wanted, lanes[wanted])
836
+ refuse('not_in_container', f'no unregistered session {wanted!r} in this container')
837
+ else:
838
+ found = C.whoami(self.root, self.environ)
839
+ ghost = found.get('session') if found['kind'] == 'unregistered' else None
840
+ if ghost is None:
841
+ if found.get('entry'):
842
+ self.refuse_lane(found['lane'], found['entry'])
843
+ if found['lane']:
844
+ refuse('not_tmux', f'{found["lane"]} is no tmux session of this container\'s status '
845
+ f'({found.get("why") or "not listed"})')
846
+ refuse('not_in_lane', found.get('why') or 'this agent runs in no lane',
847
+ 'Run it from inside an unregistered tmux session, or pass `session` (lanes_list shows the '
848
+ 'unregistered ones).')
849
+ tmux = ghost.get('tmux') if isinstance(ghost.get('tmux'), dict) else {}
850
+ socket, session = tmux.get('socket'), tmux.get('session')
851
+ if not (isinstance(socket, str) and C.IDENT.match(socket) and isinstance(session, str)
852
+ and C.TMUX_SESSION.match(session)):
853
+ refuse('invalid_session', f'the tmux session {socket!r}/{session!r} cannot be declared as a lane')
854
+ for lane_id, entry in lanes.items():
855
+ if C.tmux_target(entry) == (socket, session):
856
+ self.refuse_lane(lane_id, entry)
857
+ boards = [board for board in (ghost.get('boards') or [ghost.get('workspace')]) if board]
858
+ plan = self.plan(lanes, status, boards, arguments.get('repo'), name, ghost.get('workspace'))
859
+ if not self.session_state(socket, session):
860
+ refuse('session_gone', f'the tmux session {socket}/{session} is not running')
861
+ lane_id = plan['lane_id']
862
+ recipe = C.canonical_recipe(lane_id, plan['ws'], socket, session, plan['folder'])
863
+ terminal = {'name': name, 'command': C.LANE_OPEN_COMMAND + lane_id}
864
+ entry = {'id': lane_id, 'holder': 'tmux', 'tmux': {'socketName': socket, 'sessionName': session},
865
+ 'create': recipe}
866
+ self.edit_source(plan['path'], plan['source'], lambda text: C.terminals_add(text, terminal, entry))
867
+ out = {'lane_id': lane_id, 'workspace': plan['ws'], 'name': name, 'source': plan['source'],
868
+ 'repo': plan['repo'], 'tmux': {'socket': socket, 'session': session}, 'was': ghost.get('id'),
869
+ 'changed': f'{plan["source"]}: appended the terminal {name!r} (command `lane open {lane_id}`) and its '
870
+ f'lanes.terminals entry (tmux {socket}/{session}, the canonical create recipe); the '
871
+ 'session itself is untouched',
872
+ 'picked_up_by': SYNC_PICKUP, 'expected_within_s': SYNC_LATENCY_S}
873
+ if len(plan['ids']) > 1: # one terminals.json read by several boards: the lane shows on each
874
+ out['also'] = {ws: other for ws, other in plan['ids'].items() if ws != plan['ws']}
875
+ return out
876
+
877
+ def tool_unregister(self, arguments):
878
+ lane = arguments['lane']
879
+ entry, ghost, _status = self.find_write(lane)
880
+ if ghost:
881
+ refuse('unregistered', f'{lane} is an unregistered session: there is no declaration to remove',
882
+ 'Nothing to unregister. A person closes the session on the board if it should end.')
883
+ path, _doc, name = self.declared_target(lane, entry)
884
+ self.edit_source(path, entry['source'], lambda text: C.terminals_remove(text, name))
885
+ target = C.tmux_target(entry)
886
+ return {'lane': lane, 'removed': name, 'source': entry['source'], 'repo': entry.get('repo'),
887
+ 'tmux': {'socket': target[0], 'session': target[1]} if target else None,
888
+ 'changed': f'{entry["source"]}: removed the terminal {name!r} and its lanes.terminals entry',
889
+ 'session': 'keeps running: nothing was stopped; it shows as an unregistered session once lanes sync '
890
+ 'drops the declaration',
891
+ 'picked_up_by': SYNC_PICKUP, 'expected_within_s': SYNC_LATENCY_S}
892
+
893
+ def tool_rename(self, arguments):
894
+ lane, display = arguments['lane'], arguments['display_name']
895
+ if self.unprintable(display) or display != display.strip() or not C.shown_key(display):
896
+ refuse('invalid_name', f'{display!r}: a display name is one line of visible text, without leading or '
897
+ 'trailing spaces', 'Pass one line of visible text.')
898
+ entry, ghost, _status = self.find_write(lane)
899
+ if ghost:
900
+ refuse('unregistered', f'{lane} is an unregistered session: only a declared lane has a display name here',
901
+ 'Declare it first: lanes_register (its name is the display name). A person can also rename it on '
902
+ 'the board (R).')
903
+ if entry.get('registered_session'):
904
+ self.refuse_lane(lane, entry)
905
+ key = C.shown_key(display)
906
+ for other_id, other in self.lanes()[0].items():
907
+ if other_id != lane and other.get('ws') == entry.get('ws') and key in {
908
+ C.shown_key(str(other.get(field) or '')) for field in ('name', 'registry_name')}:
909
+ refuse('duplicate_name', f'{display!r} is the name of {other_id} on board {entry.get("ws")}: the board '
910
+ 'would drop it', lane=other_id)
911
+ path, doc, name = self.declared_target(lane, entry)
912
+ title = entry.get('registry_name')
913
+ target = None if display == title else display
914
+ current = ((C._lanes_terminals(doc) or {}).get(name) or {}).get('displayName')
915
+ before, after = self.edit_source(path, entry['source'],
916
+ lambda text: C.terminals_set_display(text, name, target))
917
+ if before == after:
918
+ changed = 'nothing: that is its display name already'
919
+ elif target is None:
920
+ changed = f'{entry["source"]}: removed lanes.terminals[{name!r}].displayName (the lane shows its title)'
921
+ else:
922
+ changed = f'{entry["source"]}: lanes.terminals[{name!r}].displayName = {display!r}'
923
+ out = {'lane': lane, 'display_name': display, 'terminal': name, 'source': entry['source'], 'changed': changed,
924
+ 'picked_up_by': SYNC_PICKUP, 'expected_within_s': SYNC_LATENCY_S}
925
+ shown = entry.get('name')
926
+ if shown not in (title, current, None): # what the board shows comes from neither layer of the file
927
+ out['host_override'] = shown
928
+ out['note'] = (f'the host names this lane {shown!r} (its workspace manifest, which wins): the boards keep '
929
+ 'showing that until a person clears it there')
930
+ return out
931
+
932
+ def tool_new(self, arguments):
933
+ name = arguments['name']
934
+ problem = C.lane_name_problem(name)
935
+ if problem:
936
+ refuse('invalid_name', f'{name!r}: {problem}')
937
+ command = arguments.get('command') or 'bash -l'
938
+ if self.unprintable(command) or not command.strip():
939
+ refuse('invalid_command', f'the command holds {", ".join(self.unprintable(command)) or "nothing"}')
940
+ lanes, status = self.lanes()
941
+ ws = arguments.get('workspace')
942
+ if not ws:
943
+ found = C.whoami(self.root, self.environ)
944
+ ws = (found.get('entry') or {}).get('ws') or (found.get('session') or {}).get('workspace')
945
+ if not ws:
946
+ boards = sorted({entry.get('ws') for entry in self.declared(lanes).values() if entry.get('ws')})
947
+ if len(boards) != 1:
948
+ refuse('ambiguous_workspace', f'this agent runs in no lane, and this container holds the lanes of '
949
+ f'{len(boards)} boards', workspaces=boards)
950
+ ws = boards[0]
951
+ socket, session = C.SHELL_SOCKET + ws, C.slug(name)[:40]
952
+ plan = self.plan(lanes, status, [ws], arguments.get('repo'), name, ws)
953
+ cwd = self.check_cwd(arguments.get('cwd') or plan['folder'])
954
+ for lane_id, entry in lanes.items():
955
+ if C.tmux_target(entry) == (socket, session):
956
+ refuse('duplicate_binding', f'{lane_id} is declared on tmux {socket}/{session} already', lane=lane_id)
957
+ if self.session_state(socket, session):
958
+ refuse('session_exists', f'a tmux session {session!r} runs on {socket} already')
959
+ lane_id = plan['lane_id']
960
+ recipe = C.canonical_recipe(lane_id, ws, socket, session, cwd, command)
961
+ terminal = {'name': name, 'command': C.LANE_OPEN_COMMAND + lane_id}
962
+ entry = {'id': lane_id, 'holder': 'tmux', 'tmux': {'socketName': socket, 'sessionName': session},
963
+ 'create': recipe}
964
+ self.edit_source(plan['path'], plan['source'], lambda text: C.terminals_add(text, terminal, entry))
965
+ changed = (f'{plan["source"]}: appended the terminal {name!r} and its lanes.terminals entry (tmux '
966
+ f'{socket}/{session}, the canonical create recipe)')
967
+ started = C.run_tmux(C.new_session_argv(socket, session, cwd, lane_id, ws, command), env=C.creation_env())
968
+ if started.returncode != 0:
969
+ why = (started.stderr or '').strip().splitlines()
970
+ refuse('tmux_failed', f'declared, but tmux did not start the session: '
971
+ f'{why[0] if why else "exit " + str(started.returncode)}',
972
+ f'The lane is declared: opening it on a board, or `~/.lz/bin/lane open {lane_id}` once lanes sync '
973
+ 'has run, starts it from its recipe.', changed=changed, lane_id=lane_id,
974
+ picked_up_by=SYNC_PICKUP, expected_within_s=SYNC_LATENCY_S)
975
+ out = {'lane_id': lane_id, 'workspace': ws, 'name': name, 'source': plan['source'], 'repo': plan['repo'],
976
+ 'tmux': {'socket': socket, 'session': session}, 'cwd': cwd, 'command': command,
977
+ 'changed': changed + f'; started tmux session {session!r} on {socket} in {cwd}',
978
+ 'picked_up_by': SYNC_PICKUP, 'expected_within_s': SYNC_LATENCY_S}
979
+ if len(plan['ids']) > 1:
980
+ out['also'] = {board: other for board, other in plan['ids'].items() if board != ws}
981
+ return out
982
+
983
+ def tool_send(self, arguments):
984
+ lane, text = arguments['lane'], arguments['text']
985
+ enter, force = arguments.get('enter', True), arguments.get('force', False)
986
+ bad = self.unprintable(text)
987
+ if bad:
988
+ refuse('invalid_text', f'the text holds {", ".join(bad)}: lanes_send types printable text only')
989
+ entry, ghost, status = self.find_write(lane)
990
+ if entry:
991
+ target = C.tmux_target(entry)
992
+ if not target:
993
+ refuse('not_tmux', f'{lane} is held by {entry.get("holder")}, not tmux')
994
+ else:
995
+ tmux = ghost.get('tmux') if isinstance(ghost.get('tmux'), dict) else {}
996
+ if not (tmux.get('socket') and tmux.get('session')):
997
+ refuse('not_tmux', f'{lane} has no tmux target in ~/.lz/status.json')
998
+ target = (tmux['socket'], tmux['session'])
999
+ if not force:
1000
+ observed = (status['lanes'].get(lane) or {}).get('agent')
1001
+ hook = C.hook_agent(self.root / 'state' / 'hooks', lane, self.proc)
1002
+ for said_by, agent in (('the host\'s observation', observed), ('its lane-signal hook', hook)):
1003
+ state = agent.get('status') if isinstance(agent, dict) else None
1004
+ if state == 'working':
1005
+ refuse('agent_working', f'{lane}\'s agent is working ({said_by}): text typed now lands in the '
1006
+ 'middle of its turn')
1007
+ if state == 'needs_approval':
1008
+ refuse('agent_needs_approval', f'{lane}\'s agent waits for an approval ({said_by}): typed text '
1009
+ 'would answer its prompt')
1010
+ results = C.send_keys(target[0], target[1], text, enter)
1011
+ result = results[-1]
1012
+ if result.returncode:
1013
+ why = (result.stderr or '').strip().splitlines()
1014
+ why = why[0] if why else 'tmux exit ' + str(result.returncode)
1015
+ if len(results) == 2: # the text went in; its Enter did not
1016
+ refuse('tmux_unanswered' if result.returncode in (124, 127) else 'tmux_failed',
1017
+ f'{lane}: typed the text, but Enter was not pressed: {why}',
1018
+ 'The text waits at the lane\'s prompt: a person presses Enter there, or clears it.',
1019
+ changed=f'typed {len(text)} characters into {lane}, without Enter')
1020
+ refuse('tmux_unanswered' if result.returncode in (124, 127) else 'tmux_failed',
1021
+ f'{lane}: {why} — nothing was typed')
1022
+ return {'lane': lane, 'typed': len(text), 'enter': enter, 'forced': bool(force),
1023
+ 'changed': f'typed {len(text)} characters{" and Enter" if enter else ""} into {lane}',
1024
+ 'picked_up_by': 'the program in the lane\'s active pane', 'expected_within_s': 0}
1025
+
1026
+ def tool_signal(self, arguments):
1027
+ word, note = arguments['status'], arguments.get('note')
1028
+ if note is not None and self.unprintable(note):
1029
+ refuse('invalid_note', f'the note holds {", ".join(self.unprintable(note))}')
1030
+ found = C.whoami(self.root, self.environ)
1031
+ lane = found['lane']
1032
+ if not lane:
1033
+ refuse('not_in_lane', found.get('why') or 'this agent runs in no lane',
1034
+ 'lanes_signal reports the lane this agent runs in: start the agent inside a lane (for Codex, '
1035
+ '`lane mcp install` passes the lane\'s variables to this server).')
1036
+ client = self.client.lower()
1037
+ harness = ('claude' if 'claude' in client else 'codex' if 'codex' in client
1038
+ else 'claude' if self.environ.get('CLAUDE_CODE_ENTRYPOINT') else '')
1039
+ # The agent is this server's parent (Claude Code and Codex start it directly): lane-signal records ITS pid,
1040
+ # so the host's liveness check follows the agent, not this process.
1041
+ argv = [sys.executable, str(self.signal), word, '--event', 'lanes_signal', '--agent-pid', str(os.getppid())]
1042
+ argv += ['--harness', harness] if harness else []
1043
+ argv += ['--note', note] if note is not None else []
1044
+ env = {'PATH': os.environ.get('PATH') or '/usr/bin:/bin', 'HOME': str(self.home), 'LANG': 'C.UTF-8',
1045
+ 'LANE_ROOT': str(self.root), 'LANE_ID': lane}
1046
+ # The agent's session, so the sidecar names the session its own hooks name (their stdin's `session_id`):
1047
+ # Claude Code gives its MCP servers CLAUDE_CODE_SESSION_ID (MEASURED 2026-10-06 in a server's environ; it
1048
+ # equals the session's transcript id). lane-signal matches a report to the agent's hooks by the agent's pid and
1049
+ # starttime first (Codex clears a server's environment; a /clear gives the agent a new session), then by this
1050
+ # session (DEF-1801).
1051
+ env.update({key: self.environ[key] for key in SESSION_VARS if self.environ.get(key)})
1052
+ started = int(time.time())
1053
+ try:
1054
+ subprocess.run(argv, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1055
+ stderr=subprocess.DEVNULL, timeout=10)
1056
+ except (OSError, subprocess.SubprocessError) as error:
1057
+ refuse('signal_not_written', f'lane-signal did not run: {error}')
1058
+ side = C.read_json(self.root / 'state' / 'hooks' / f'{lane}.json')
1059
+ if not (isinstance(side, dict) and side.get('status') == word and side.get('event') == 'lanes_signal'
1060
+ and isinstance(side.get('at'), int) and side['at'] >= started):
1061
+ refuse('signal_not_written', f'lane-signal wrote no sidecar for {lane} (~/.lz/state/hooks/{lane}.json)')
1062
+ return {'lane': lane, 'status': word, 'since': side.get('since'), 'note': side.get('name') or None,
1063
+ 'changed': f'~/.lz/state/hooks/{lane}.json: status {word}'
1064
+ + (', note set' if side.get('name') else ', no note'),
1065
+ 'stands_until': SIGNAL_STANDS if word != 'working' else SIGNAL_WORKING,
1066
+ 'picked_up_by': STATUS_PICKUP, 'expected_within_s': STATUS_LATENCY_S}
1067
+
1068
+
1069
+ def main(argv):
1070
+ if argv:
1071
+ if argv[0] in ('-h', '--help'):
1072
+ sys.stderr.write(__doc__)
1073
+ return 0
1074
+ sys.stderr.write('usage: lane-mcp (an MCP server on stdin/stdout, started by the agent from its MCP config; '
1075
+ 'lane mcp install adds it)\n')
1076
+ return 2
1077
+ root = Path(os.environ.get('LANE_ROOT') or C.LZ)
1078
+ log = Log(root / 's' / 'logs' / 'lane-mcp.log')
1079
+ out = os.fdopen(os.dup(1), 'wb', buffering=0) # the protocol stream: frames only
1080
+ try:
1081
+ devnull = os.open(os.devnull, os.O_WRONLY)
1082
+ os.dup2(devnull, 1)
1083
+ os.close(devnull)
1084
+ except OSError:
1085
+ pass
1086
+ sys.stdout = sys.stderr # a print() can never reach the protocol stream
1087
+ return Server(root, log=log).serve(sys.stdin.buffer, out)
1088
+
1089
+
1090
+ if __name__ == '__main__':
1091
+ sys.exit(main(sys.argv[1:]))