@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,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:]))
|