@vibelord/lanes 1.5.0 → 1.6.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/package.json +1 -1
- package/payload/lanes/README.md +1 -1
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +111 -25
- package/payload/lanes/client/ghostty/common.conf +4 -0
- package/payload/lanes/client/ghostty/lanes-themed-window +73 -0
- package/payload/lanes/client/ghostty/omarchy.conf +4 -2
- package/payload/lanes/container/hooks/muse-hooks.json +13 -0
- package/payload/lanes/container/hooks/zcode-hooks.json +9 -0
- package/payload/lanes/container/lane +7 -4
- package/payload/lanes/container/lane-mcp +13 -5
- package/payload/lanes/container/lane-signal +88 -21
- package/payload/lanes/container/lane_core.py +143 -19
- package/payload/lanes/container/throttle-pulse +9 -6
- package/payload/lanes/docs/ARCHITECTURE.md +58 -11
- package/payload/lanes/docs/INSTALL.md +10 -6
- package/payload/lanes/docs/RUNBOOK.md +125 -16
- package/payload/lanes/docs/USERGUIDE.md +77 -28
- package/payload/lanes/install.sh +2 -1
- package/payload/lanes/libexec/agent_state.py +90 -14
- package/payload/lanes/libexec/attach.py +181 -0
- package/payload/lanes/libexec/harness_muse.py +531 -0
- package/payload/lanes/libexec/lanes_lib.py +3 -2
- package/payload/lanes/libexec/sidebar.py +9 -6
- package/payload/lanes/libexec/statusd.py +16 -6
- package/payload/manifest.json +4 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""lane-signal <status> [--event NAME] [--harness claude|codex|kimi|grok|grok-cli|unknown] [--note TEXT]
|
|
3
|
-
[--agent-pid PID] [PAYLOAD] — the hook target of Claude Code, Codex, Kimi Code, Grok Build
|
|
4
|
-
workspace container, and the writer behind lane-mcp's `lanes_signal`. Writes ~/.lz/state/hooks/<lane id>.json
|
|
2
|
+
"""lane-signal <status> [--event NAME] [--harness claude|codex|kimi|grok|grok-cli|muse|zcode|unknown] [--note TEXT]
|
|
3
|
+
[--agent-pid PID] [PAYLOAD] — the hook target of Claude Code, Codex, Kimi Code, Grok Build, grok-cli, Muse Code and
|
|
4
|
+
ZCode inside a workspace container, and the writer behind lane-mcp's `lanes_signal`. Writes ~/.lz/state/hooks/<lane id>.json
|
|
5
5
|
(atomic replace), which the host's lanes-statusd reads through /proc/<container init pid>/root and publishes as the
|
|
6
6
|
lane's `agent{}` (source `hook`).
|
|
7
7
|
|
|
@@ -10,7 +10,7 @@ The sidecar has its own directory because `lane-run`, the root process of every
|
|
|
10
10
|
sidecar at state/<lane id>.json would overwrite that record for the agent's whole life (DEF-1572). Nothing but
|
|
11
11
|
this script writes state/hooks/, and lane-run never writes there.
|
|
12
12
|
|
|
13
|
-
{"v":1, "lane":"<id>", "harness":"claude|codex|kimi|grok|grok-cli|unknown", "status":"<agent{} vocabulary>",
|
|
13
|
+
{"v":1, "lane":"<id>", "harness":"claude|codex|kimi|grok|grok-cli|muse|zcode|unknown", "status":"<agent{} vocabulary>",
|
|
14
14
|
"since":<epoch the status began>, "at":<epoch of this event>, "event":"<hook event>", "pid":<agent pid>,
|
|
15
15
|
"start":"<its starttime>", "session":"<session/thread id or ''>", "name":"<the agent's own note>" (only when
|
|
16
16
|
there is one)}
|
|
@@ -24,7 +24,11 @@ Lane id: $LANE_ID — exported in a tmux lane only by a canonical/`--identity` r
|
|
|
24
24
|
`<ws>--z-<name>` id lanes-statusd publishes for it, so statusd does not join that sidecar to the zmx lane yet:
|
|
25
25
|
DEF-1573) — else the pane's tmux session (`tmux -S ${TMUX%%,*} display -p -t $TMUX_PANE '#S'`) looked up by (socket
|
|
26
26
|
name, session) in ~/.lz/registry.json, the projection `lanes sync` writes. lane-mcp passes the lane it resolved as
|
|
27
|
-
$LANE_ID.
|
|
27
|
+
$LANE_ID. W13 (DEF-2031): when this process has neither LANE_ID nor TMUX — Muse Code runs a hook with an environment
|
|
28
|
+
cleared to an allow-list (HOME, PATH, TERM, LANG, LC_ALL, USER, LOGNAME, SHELL, TMPDIR, MUSE_SESSION_ID,
|
|
29
|
+
MUSE_THREAD_ID; MEASURED 2026-10-16, muse 1.4.10) — the agent's own /proc/<pid>/environ stands in for LANE_ID, LANE_WS,
|
|
30
|
+
TMUX and TMUX_PANE, read only when that pid is a known harness, bounded (ENVIRON_MAX) and without following a link
|
|
31
|
+
(`agent_environ`, lane_core.parent_environ's rule, pinned equal by test_lane_signal).
|
|
28
32
|
|
|
29
33
|
Agent pid: --agent-pid when that process exists (lane-mcp passes its own parent, the agent that started it: the
|
|
30
34
|
default rule below would name lane-mcp itself); else the parent process, or that parent's parent when the parent is
|
|
@@ -32,12 +36,15 @@ a shell (Claude Code runs a hook command through a shell, which may fork it rath
|
|
|
32
36
|
with the shell would read as a dead agent).
|
|
33
37
|
Session id: `session_id` of the hook's JSON on stdin (Grok Build's camel-case `sessionId` too), `thread-id` of a JSON
|
|
34
38
|
PAYLOAD argument (Codex `notify` passes its JSON as the last argument), else $CLAUDE_CODE_SESSION_ID / $CODEX_THREAD_ID
|
|
35
|
-
/ $GROK_SESSION_ID (lane-mcp passes
|
|
36
|
-
Grok Build also
|
|
39
|
+
/ $GROK_SESSION_ID / $MUSE_SESSION_ID / $ZCODE_SESSION_ID (lane-mcp passes them: Claude Code, Grok Build and Muse Code
|
|
40
|
+
give them to their MCP servers; Grok Build, Muse Code and ZCode also set theirs for every hook they run).
|
|
41
|
+
A PostToolUseFailure whose hook JSON says `is_interrupt: true` is the person interrupting the turn: needs_input,
|
|
42
|
+
whatever status the hook names (ZCode sends no Stop after an interrupt, DEF-2091).
|
|
37
43
|
Harness (W12, DEF-1990): --harness when it is one of HARNESSES, else $LANE_HARNESS (the same rule), else the first
|
|
38
44
|
process up the parent chain (PARENT_DEPTH steps from this process's parent) whose comm is a harness's
|
|
39
|
-
(HARNESS_COMMS: claude, codex, kimi, kimi-code, grok; a `grok` is Grok Build when /proc/<pid>/exe ends
|
|
40
|
-
GROK_BUILD_EXE, else grok-cli;
|
|
45
|
+
(HARNESS_COMMS: claude, codex, kimi, kimi-code, grok, muse, zcode-cli; a `grok` is Grok Build when /proc/<pid>/exe ends
|
|
46
|
+
in GROK_BUILD_EXE, else grok-cli; a comm starting `muse-bin-` or an exe whose file name does is Muse Code; any comm
|
|
47
|
+
whose exe holds CLAUDE_EXE is Claude Code), else `unknown` — never `codex`:
|
|
41
48
|
before W12 every hook without --harness and outside Claude Code was recorded as Codex. $LANE_PROC names another /proc for the chain and the agent's starttime (tests).
|
|
42
49
|
Stdin is read only when it is not a terminal and for at most STDIN_WAIT seconds: a stdin that never closes cannot hang
|
|
43
50
|
the hook.
|
|
@@ -62,6 +69,7 @@ import json
|
|
|
62
69
|
import os
|
|
63
70
|
import re
|
|
64
71
|
import select
|
|
72
|
+
import stat
|
|
65
73
|
import subprocess
|
|
66
74
|
import sys
|
|
67
75
|
import time
|
|
@@ -76,12 +84,16 @@ NOTE_MAX = 120
|
|
|
76
84
|
EXPLICIT = 'lanes_signal' # the --event of lane-mcp's lanes_signal: the agent's own word
|
|
77
85
|
REPORT_TOOL = 'lanes_signal' # its tool name; Claude Code names an MCP tool mcp__<server>__<tool>
|
|
78
86
|
RESTING = {('Stop', 'needs_input'), ('Stop', 'done'), ('Notification', 'needs_input')} # the hooks' "turn ended"
|
|
79
|
-
SESSION_VARS = ('CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID', 'GROK_SESSION_ID')
|
|
80
|
-
HARNESSES = ('claude', 'codex', 'kimi', 'grok', 'grok-cli', 'unknown') # agent{}.harness, but `shell`
|
|
81
|
-
HARNESS_COMMS = {'claude': 'claude', 'codex': 'codex', 'kimi': 'kimi', 'kimi-code': 'kimi', 'grok': 'grok'
|
|
87
|
+
SESSION_VARS = ('CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID', 'GROK_SESSION_ID', 'MUSE_SESSION_ID', 'ZCODE_SESSION_ID')
|
|
88
|
+
HARNESSES = ('claude', 'codex', 'kimi', 'grok', 'grok-cli', 'muse', 'zcode', 'unknown') # agent{}.harness, but `shell`
|
|
89
|
+
HARNESS_COMMS = {'claude': 'claude', 'codex': 'codex', 'kimi': 'kimi', 'kimi-code': 'kimi', 'grok': 'grok',
|
|
90
|
+
'muse': 'muse', 'zcode-cli': 'zcode'}
|
|
91
|
+
MUSE_PREFIX = 'muse-bin-' # Muse Code's binary `muse-bin-<version>` and its comm (MEASURED 2026-10-16: muse-bin-1.4.10)
|
|
82
92
|
GROK_BUILD_EXE = '/downloads/grok-linux-x86_64' # Grok Build's file ($GROK_HOME/downloads/…); grok-cli's: bin/grok
|
|
83
93
|
CLAUDE_EXE = '/claude/versions/' # native Claude Code before 2.1.299 kept its file's name, the version, as comm (MEASURED)
|
|
84
94
|
PARENT_DEPTH = 8
|
|
95
|
+
ENVIRON_MAX = 1 << 20 # lane_core.ENVIRON_MAX: /proc/<pid>/environ read at most this far
|
|
96
|
+
IDENTITY_VARS = ('LANE_ID', 'LANE_WS', 'TMUX', 'TMUX_PANE') # lane_core.WHOAMI_VARS
|
|
85
97
|
|
|
86
98
|
|
|
87
99
|
def options(argv):
|
|
@@ -109,12 +121,13 @@ def clean_note(text):
|
|
|
109
121
|
return ' '.join(kept.split())[:NOTE_MAX]
|
|
110
122
|
|
|
111
123
|
|
|
112
|
-
def lane_id(root):
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
124
|
+
def lane_id(root, environ=None):
|
|
125
|
+
environ = os.environ if environ is None else environ
|
|
126
|
+
found = environ.get('LANE_ID', '')
|
|
127
|
+
if not found and environ.get('TMUX') and environ.get('TMUX_PANE'):
|
|
128
|
+
sock = environ['TMUX'].split(',', 1)[0]
|
|
116
129
|
try:
|
|
117
|
-
done = subprocess.run(['tmux', '-S', sock, 'display', '-p', '-t',
|
|
130
|
+
done = subprocess.run(['tmux', '-S', sock, 'display', '-p', '-t', environ['TMUX_PANE'], '#S'],
|
|
118
131
|
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=1)
|
|
119
132
|
session = done.stdout.strip() if done.returncode == 0 else ''
|
|
120
133
|
lanes = json.load(open(os.path.join(root, 'registry.json'))).get('lanes') or {}
|
|
@@ -178,7 +191,54 @@ def process_harness(comm, pid, proc='/proc'):
|
|
|
178
191
|
return 'grok' if exe_of(pid, proc).endswith(GROK_BUILD_EXE) else 'grok-cli'
|
|
179
192
|
if found:
|
|
180
193
|
return found
|
|
181
|
-
|
|
194
|
+
if comm.startswith(MUSE_PREFIX):
|
|
195
|
+
return 'muse'
|
|
196
|
+
exe = exe_of(pid, proc)
|
|
197
|
+
if CLAUDE_EXE in exe:
|
|
198
|
+
return 'claude'
|
|
199
|
+
return 'muse' if exe.rpartition('/')[2].startswith(MUSE_PREFIX) else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def agent_environ(pid, proc='/proc', names=IDENTITY_VARS):
|
|
203
|
+
"""{name: value} of `names` in /proc/<pid>/environ when `pid` is a known harness (process_harness) — {} otherwise
|
|
204
|
+
(lane_core.parent_environ's rule). Bounded (ENVIRON_MAX), never through a link (O_NOFOLLOW on the file; /proc's own
|
|
205
|
+
entries are not links), never blocking; entries are NUL-split and one that is not UTF-8 or holds no `=` is skipped."""
|
|
206
|
+
try:
|
|
207
|
+
pid = int(pid)
|
|
208
|
+
if pid <= 1:
|
|
209
|
+
return {}
|
|
210
|
+
comm = stat_fields(pid, proc)[0]
|
|
211
|
+
except (OSError, ValueError, IndexError, TypeError):
|
|
212
|
+
return {}
|
|
213
|
+
if not process_harness(comm, pid, proc):
|
|
214
|
+
return {}
|
|
215
|
+
try:
|
|
216
|
+
fd = os.open(f'{proc}/{pid}/environ', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0))
|
|
217
|
+
except OSError:
|
|
218
|
+
return {}
|
|
219
|
+
try:
|
|
220
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
221
|
+
return {}
|
|
222
|
+
data, chunk = b'', b'x'
|
|
223
|
+
while chunk and len(data) <= ENVIRON_MAX:
|
|
224
|
+
chunk = os.read(fd, ENVIRON_MAX + 1 - len(data))
|
|
225
|
+
data += chunk
|
|
226
|
+
except OSError:
|
|
227
|
+
return {}
|
|
228
|
+
finally:
|
|
229
|
+
os.close(fd)
|
|
230
|
+
if len(data) > ENVIRON_MAX:
|
|
231
|
+
return {}
|
|
232
|
+
found = {}
|
|
233
|
+
for item in data.split(b'\0'):
|
|
234
|
+
key, sep, value = item.partition(b'=')
|
|
235
|
+
try:
|
|
236
|
+
key, value = key.decode('utf-8'), value.decode('utf-8')
|
|
237
|
+
except UnicodeDecodeError:
|
|
238
|
+
continue
|
|
239
|
+
if sep and key in names and value and key not in found:
|
|
240
|
+
found[key] = value
|
|
241
|
+
return found
|
|
182
242
|
|
|
183
243
|
|
|
184
244
|
def parent_harness(proc='/proc', start=None):
|
|
@@ -253,10 +313,19 @@ def main(argv):
|
|
|
253
313
|
if status not in STATUSES:
|
|
254
314
|
return
|
|
255
315
|
root = os.environ.get('LANE_ROOT') or os.path.join(os.path.expanduser('~'), '.lz')
|
|
256
|
-
|
|
316
|
+
proc = os.environ.get('LANE_PROC') or '/proc'
|
|
317
|
+
pid, start = agent(agent_pid, proc)
|
|
318
|
+
environ = None
|
|
319
|
+
if not os.environ.get('LANE_ID') and not os.environ.get('TMUX'):
|
|
320
|
+
inherited = agent_environ(pid, proc) # a harness that cleared its hooks' environment (Muse Code, DEF-2031)
|
|
321
|
+
if inherited.get('LANE_ID') or (inherited.get('TMUX') and inherited.get('TMUX_PANE')):
|
|
322
|
+
environ = dict(os.environ, **inherited)
|
|
323
|
+
lane = lane_id(root, environ)
|
|
257
324
|
if not lane:
|
|
258
325
|
return
|
|
259
326
|
hook = stdin_doc()
|
|
327
|
+
if event == 'PostToolUseFailure' and hook.get('is_interrupt') is True:
|
|
328
|
+
status = 'needs_input' # the person interrupted the turn; ZCode sends no Stop after it (DEF-2091)
|
|
260
329
|
session = str(hook.get('session_id') or hook.get('sessionId') or '')
|
|
261
330
|
if not session and payload:
|
|
262
331
|
try:
|
|
@@ -265,10 +334,8 @@ def main(argv):
|
|
|
265
334
|
except ValueError:
|
|
266
335
|
session = ''
|
|
267
336
|
session = (session or next((os.environ[key] for key in SESSION_VARS if os.environ.get(key)), ''))[:128]
|
|
268
|
-
proc = os.environ.get('LANE_PROC') or '/proc'
|
|
269
337
|
given_harness = harness if harness in HARNESSES else ''
|
|
270
338
|
harness = harness_of(harness, proc)
|
|
271
|
-
pid, start = agent(agent_pid, proc)
|
|
272
339
|
now = int(time.time())
|
|
273
340
|
state = os.path.join(root, 'state', 'hooks') # never state/<id>.json: lane-run's phase record (DEF-1572)
|
|
274
341
|
path = os.path.join(state, f'{lane}.json')
|
|
@@ -59,7 +59,10 @@ STATUS_FRESH = 60 # status.json `at` is renewed at least every 15 s (sta
|
|
|
59
59
|
READ_MAX = 16 << 20 # a projection bigger than this is not ours
|
|
60
60
|
TMUX_TIMEOUT = 5
|
|
61
61
|
IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE', 'LANES_TRACE') # + our trace headers and GROK_HOME (identity_free)
|
|
62
|
-
|
|
62
|
+
# Ours: 'x-throttle-trace: lanes/<ws>/<id>'. W13: MUSE_CUSTOM_HEADERS reaches Muse Code's model requests (MEASURED
|
|
63
|
+
# 2026-10-16 on muse 1.4.10: the header arrived on its `GET …/models`; undocumented by Meta, so it may stop working in a
|
|
64
|
+
# later release — DEF-2059). ZCode has no per-process header (its api.headers are static config): no trace.
|
|
65
|
+
TRACE_HEADERS = ('ANTHROPIC_CUSTOM_HEADERS', 'KIMI_CODE_CUSTOM_HEADERS', 'MUSE_CUSTOM_HEADERS')
|
|
63
66
|
GROK_HOME = CONTAINER_HOME + '/.grok-build' # Grok Build's home in a lane, never grok-cli's ~/.grok (DM-LANES-GROK-HOME)
|
|
64
67
|
|
|
65
68
|
|
|
@@ -213,7 +216,8 @@ def identity_free(environ, drop=('TMUX',)):
|
|
|
213
216
|
a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
|
|
214
217
|
that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1559). Board sockets are shared by
|
|
215
218
|
many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
|
|
216
|
-
An operator's own ANTHROPIC_CUSTOM_HEADERS / KIMI_CODE_CUSTOM_HEADERS that is not a lane trace
|
|
219
|
+
An operator's own ANTHROPIC_CUSTOM_HEADERS / KIMI_CODE_CUSTOM_HEADERS / MUSE_CUSTOM_HEADERS that is not a lane trace
|
|
220
|
+
is theirs and stays,
|
|
217
221
|
and so is a GROK_HOME that is not the one the recipe stamps (W12)."""
|
|
218
222
|
return {k: v for k, v in environ.items() if k not in drop and k not in IDENTITY
|
|
219
223
|
and not (k in TRACE_HEADERS and v.startswith('x-throttle-trace: lanes/'))
|
|
@@ -309,7 +313,7 @@ def last_lines(text, lines):
|
|
|
309
313
|
def new_session_argv(socket, session, cwd, lane_id, ws, command):
|
|
310
314
|
"""The one tmux client lane-mcp runs WITHOUT -N (`lanes_new`): it creates the session, and starts the socket's
|
|
311
315
|
server when none runs. The lane's identity reaches the session only through these `-e` flags, the canonical
|
|
312
|
-
recipe's
|
|
316
|
+
recipe's eight (the client's own environment is creation_env(), an allow-list: whatever client starts a server
|
|
313
317
|
hands its environment to every later session there, DEF-1559, DEF-1802). `command` is one shell-command string,
|
|
314
318
|
run by the server's default shell like the recipe's command."""
|
|
315
319
|
trace = lane_trace(lane_id, ws)
|
|
@@ -319,7 +323,7 @@ def new_session_argv(socket, session, cwd, lane_id, ws, command):
|
|
|
319
323
|
'-e', f'LANE_ID={lane_id}', '-e', f'LANE_WS={ws}',
|
|
320
324
|
'-e', f'ANTHROPIC_CUSTOM_HEADERS=x-throttle-trace: {trace}', '-e', f'THROTTLE_TRACE={trace}',
|
|
321
325
|
'-e', f'KIMI_CODE_CUSTOM_HEADERS=x-throttle-trace: {trace}', '-e', f'LANES_TRACE={trace}',
|
|
322
|
-
'-e', f'GROK_HOME={GROK_HOME}', command]
|
|
326
|
+
'-e', f'GROK_HOME={GROK_HOME}', '-e', f'MUSE_CUSTOM_HEADERS=x-throttle-trace: {trace}', command]
|
|
323
327
|
|
|
324
328
|
|
|
325
329
|
CREATION_ENV = ('HOME', 'USER', 'LOGNAME', 'PATH', 'LANG', 'LANGUAGE', 'TZ', 'TMUX_TMPDIR') # and every LC_*
|
|
@@ -392,9 +396,9 @@ def lane_trace(lane_id, ws):
|
|
|
392
396
|
|
|
393
397
|
def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
|
|
394
398
|
"""lanes_lib.canonical_recipe: the create recipe that gives a new tmux lane its identity (LANE_ID, LANE_WS, the
|
|
395
|
-
lanes/<ws>/<id> trace as ANTHROPIC_CUSTOM_HEADERS, THROTTLE_TRACE, KIMI_CODE_CUSTOM_HEADERS
|
|
396
|
-
GROK_HOME) through `-e` flags; `new -A` attaches when the session exists. LaneError where
|
|
397
|
-
LanesError."""
|
|
399
|
+
lanes/<ws>/<id> trace as ANTHROPIC_CUSTOM_HEADERS, THROTTLE_TRACE, KIMI_CODE_CUSTOM_HEADERS, LANES_TRACE and (W13)
|
|
400
|
+
MUSE_CUSTOM_HEADERS, and GROK_HOME) through `-e` flags; `new -A` attaches when the session exists. LaneError where
|
|
401
|
+
the host raises LanesError."""
|
|
398
402
|
trace = lane_trace(lane_id, ws)
|
|
399
403
|
if trace is None:
|
|
400
404
|
raise LaneError(f'Invalid lane trace: lanes/{ws}/{lane_id} (allowed: A-Z a-z 0-9 . _ - /, at most 256)',
|
|
@@ -411,7 +415,7 @@ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
|
|
|
411
415
|
return (f'tmux -L {shlex.quote(socket)} new -ADs {shlex.quote(session)} -c {shlex.quote(folder)} '
|
|
412
416
|
f'-e LANE_ID={lane_id} -e LANE_WS={ws} -e ANTHROPIC_CUSTOM_HEADERS={header} '
|
|
413
417
|
f'-e THROTTLE_TRACE={trace} -e KIMI_CODE_CUSTOM_HEADERS={header} -e LANES_TRACE={trace} '
|
|
414
|
-
f'-e GROK_HOME={GROK_HOME} {cmd}')
|
|
418
|
+
f'-e GROK_HOME={GROK_HOME} -e MUSE_CUSTOM_HEADERS={header} {cmd}')
|
|
415
419
|
|
|
416
420
|
|
|
417
421
|
def lane_name_problem(name):
|
|
@@ -486,8 +490,10 @@ def lane_kind(entry):
|
|
|
486
490
|
|
|
487
491
|
# The harnesses a process tree can name (W12; lane-signal's HARNESS_COMMS and parent chain, pinned equal by
|
|
488
492
|
# test_lane_core). A `grok` is Grok Build when its executable is $GROK_HOME/downloads/grok-linux-x86_64, else grok-cli.
|
|
489
|
-
HARNESS_COMMS = {'claude': 'claude', 'codex': 'codex', 'kimi': 'kimi', 'kimi-code': 'kimi', 'grok': 'grok'
|
|
490
|
-
|
|
493
|
+
HARNESS_COMMS = {'claude': 'claude', 'codex': 'codex', 'kimi': 'kimi', 'kimi-code': 'kimi', 'grok': 'grok',
|
|
494
|
+
'muse': 'muse', 'zcode-cli': 'zcode'}
|
|
495
|
+
HARNESSES = ('claude', 'codex', 'kimi', 'grok', 'grok-cli', 'muse', 'zcode', 'unknown')
|
|
496
|
+
MUSE_PREFIX = 'muse-bin-' # W13: Muse Code's comm and binary file name start with it (MEASURED: muse-bin-1.4.10)
|
|
491
497
|
GROK_BUILD_EXE = '/downloads/grok-linux-x86_64'
|
|
492
498
|
CLAUDE_EXE = '/claude/versions/' # native Claude Code before 2.1.299 kept its file's name, the version, as comm (MEASURED)
|
|
493
499
|
PARENT_DEPTH = 8
|
|
@@ -515,15 +521,23 @@ def _exe(proc, pid):
|
|
|
515
521
|
|
|
516
522
|
|
|
517
523
|
def process_harness(proc, pid):
|
|
518
|
-
"""The harness process `pid` is, or None: its comm in HARNESS_COMMS (a `grok` split by /proc/<pid>/exe),
|
|
519
|
-
native Claude Code file (CLAUDE_EXE)
|
|
524
|
+
"""The harness process `pid` is, or None: its comm in HARNESS_COMMS (a `grok` split by /proc/<pid>/exe), a comm
|
|
525
|
+
starting MUSE_PREFIX (Muse Code), else by its executable: a native Claude Code file (CLAUDE_EXE) or a Muse binary
|
|
526
|
+
(a file name starting MUSE_PREFIX)."""
|
|
520
527
|
found = _stat(proc, pid)
|
|
521
528
|
if not found:
|
|
522
529
|
return None
|
|
523
530
|
harness = HARNESS_COMMS.get(found[0])
|
|
524
531
|
if harness == 'grok':
|
|
525
532
|
return 'grok' if _exe(proc, pid).endswith(GROK_BUILD_EXE) else 'grok-cli'
|
|
526
|
-
|
|
533
|
+
if harness:
|
|
534
|
+
return harness
|
|
535
|
+
if found[0].startswith(MUSE_PREFIX):
|
|
536
|
+
return 'muse'
|
|
537
|
+
exe = _exe(proc, pid)
|
|
538
|
+
if CLAUDE_EXE in exe:
|
|
539
|
+
return 'claude'
|
|
540
|
+
return 'muse' if exe.rpartition('/')[2].startswith(MUSE_PREFIX) else None
|
|
527
541
|
|
|
528
542
|
|
|
529
543
|
def parent_harness(proc, pid):
|
|
@@ -1013,13 +1027,16 @@ def terminals_add(text, terminal, entry=None):
|
|
|
1013
1027
|
MCP_NAME = 'lanes'
|
|
1014
1028
|
MCP_COMMAND = LZ + '/bin/lane-mcp'
|
|
1015
1029
|
MCP_ENTRY = {'command': MCP_COMMAND}
|
|
1016
|
-
MCP_AGENTS = ('claude', 'codex', 'kimi', 'grok', 'grok-cli')
|
|
1030
|
+
MCP_AGENTS = ('claude', 'codex', 'kimi', 'grok', 'grok-cli', 'muse', 'zcode')
|
|
1017
1031
|
MCP_FILES = {'claude': '.claude.json', 'codex': '.codex/config.toml', 'kimi': '.kimi-code/mcp.json',
|
|
1018
|
-
'grok': '.grok-build/config.toml', 'grok-cli': '.grok/user-settings.json'
|
|
1032
|
+
'grok': '.grok-build/config.toml', 'grok-cli': '.grok/user-settings.json',
|
|
1033
|
+
'muse': '.config/muse/settings.json', 'zcode': '.zcode/cli/config.json'}
|
|
1019
1034
|
# W12: claude and codex get their entry in every home; kimi, grok (Grok Build, GROK_HOME) and grok-cli only where their
|
|
1020
|
-
# home directory already is a real directory (the harness is installed there), unless named (`--agent`).
|
|
1035
|
+
# home directory already is a real directory (the harness is installed there), unless named (`--agent`). W13: muse
|
|
1036
|
+
# (Muse Code's ~/.config/muse, where its auth.json and settings.json live) and zcode (~/.zcode) the same way.
|
|
1021
1037
|
MCP_ALWAYS = ('claude', 'codex')
|
|
1022
|
-
MCP_HOMES = {'kimi': '.kimi-code', 'grok': '.grok-build', 'grok-cli': '.grok'
|
|
1038
|
+
MCP_HOMES = {'kimi': '.kimi-code', 'grok': '.grok-build', 'grok-cli': '.grok', 'muse': '.config/muse',
|
|
1039
|
+
'zcode': '.zcode'}
|
|
1023
1040
|
TOML_HEADER = re.compile(r'''^[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*(?:lanes|"lanes"|'lanes')[ \t]*\][ \t]*(?:#.*)?$''')
|
|
1024
1041
|
# What Codex must hand lane-mcp. Codex starts a stdio MCP server with a CLEARED environment: its defaults (HOME,
|
|
1025
1042
|
# LOGNAME, PATH, SHELL, USER, LANG, LC_ALL, TERM, TMPDIR, TZ) plus the names in the table's `env_vars` and the
|
|
@@ -1380,11 +1397,118 @@ def grok_cli_mcp(text, command=MCP_COMMAND):
|
|
|
1380
1397
|
return after, None
|
|
1381
1398
|
|
|
1382
1399
|
|
|
1383
|
-
|
|
1400
|
+
# W13: Muse Code (muse 1.4.10) reads its user settings from ~/.config/muse/settings.json, a JSON object that must say
|
|
1401
|
+
# "schema_version": 1 (MEASURED 2026-10-16: a file without it is refused by Muse). An MCP server is `mcp_servers.<name>`
|
|
1402
|
+
# {transport, command, args, env, mode}; Muse starts a stdio server with an environment CLEARED to a few names (HOME,
|
|
1403
|
+
# PATH, TERM, LANG, MUSE_SESSION_ID) plus the entry's `env`, whose values interpolate `${VAR}` and `${VAR:-}` from
|
|
1404
|
+
# Muse's own environment (MEASURED 2026-10-16: `${LANE_ID:-}` arrived as the lane id, `${NOPE:-}` as empty). `mode`
|
|
1405
|
+
# defaults to "required", which aborts the session when the server cannot start: ours is "optional".
|
|
1406
|
+
MUSE_SCHEMA_VERSION = 1
|
|
1407
|
+
MUSE_MCP_ENV = {name: '${' + name + ':-}' for name in MCP_ENV_VARS}
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def muse_mcp_entry(command=MCP_COMMAND):
|
|
1411
|
+
return {'transport': 'stdio', 'command': command, 'args': [], 'env': dict(MUSE_MCP_ENV), 'mode': 'optional'}
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def muse_settings_doc(text, what='~/.config/muse/settings.json'):
|
|
1415
|
+
"""(doc, why) of Muse Code's settings.json text: a new document when there is none; `why` when the file is not one
|
|
1416
|
+
Lanes may edit (not an object, or no "schema_version": 1 — a file Muse itself would refuse, or a newer schema)."""
|
|
1417
|
+
if text is None or not text.strip():
|
|
1418
|
+
return {'schema_version': MUSE_SCHEMA_VERSION}, None
|
|
1419
|
+
doc = strict_json(text, what)
|
|
1420
|
+
if not isinstance(doc, dict):
|
|
1421
|
+
return None, f'{what} is not a JSON object — left alone'
|
|
1422
|
+
if doc.get('schema_version') != MUSE_SCHEMA_VERSION or isinstance(doc.get('schema_version'), bool):
|
|
1423
|
+
return None, (f'{what} does not say "schema_version": {MUSE_SCHEMA_VERSION} — left alone (Muse Code refuses '
|
|
1424
|
+
'such a file; fix it by hand first)')
|
|
1425
|
+
return doc, None
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def muse_mcp(text, command=MCP_COMMAND):
|
|
1429
|
+
"""~/.config/muse/settings.json with `mcp_servers.lanes` = muse_mcp_entry() -> (new text, why). A new file is
|
|
1430
|
+
{"schema_version": 1, "mcp_servers": {"lanes": …}}; an existing one must already say schema_version 1. An equal
|
|
1431
|
+
entry is present; an earlier install's (a lane-mcp at another path or with other env) is replaced; a `lanes`
|
|
1432
|
+
server that starts something else is left alone. The edit is that one member, every other byte kept, and the
|
|
1433
|
+
result must parse back to the input plus that member."""
|
|
1434
|
+
what = '~/.config/muse/settings.json'
|
|
1435
|
+
entry = muse_mcp_entry(command)
|
|
1436
|
+
doc, why = muse_settings_doc(text, what)
|
|
1437
|
+
if why:
|
|
1438
|
+
return text, why
|
|
1439
|
+
if text is None or not text.strip():
|
|
1440
|
+
return json.dumps(dict(doc, mcp_servers={MCP_NAME: entry}), indent=2) + '\n', None
|
|
1441
|
+
servers = doc.get('mcp_servers')
|
|
1442
|
+
if servers is not None and not isinstance(servers, dict):
|
|
1443
|
+
return text, f'mcp_servers in {what} is not an object — left alone'
|
|
1444
|
+
current = (servers or {}).get(MCP_NAME)
|
|
1445
|
+
if current == entry:
|
|
1446
|
+
return text, None
|
|
1447
|
+
if current is not None and not _an_older_ours(current):
|
|
1448
|
+
shown = current.get('command') if isinstance(current, dict) else current
|
|
1449
|
+
return text, (f'mcp_servers.{MCP_NAME} in {what} is not the entry Lanes writes (command {shown!r}) — left '
|
|
1450
|
+
'alone (remove it to let Lanes add its own)')
|
|
1451
|
+
if servers is None:
|
|
1452
|
+
after = set_member(text, [], 'mcp_servers', {MCP_NAME: entry})
|
|
1453
|
+
else:
|
|
1454
|
+
after = set_member(text, ['mcp_servers'], MCP_NAME, entry)
|
|
1455
|
+
if strict_json(after, what) != dict(doc, mcp_servers=dict(servers or {}, **{MCP_NAME: entry})):
|
|
1456
|
+
return text, 'the entry could not be added without changing anything else — add it by hand'
|
|
1457
|
+
return after, None
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
# W13: ZCode CLI (zai-org/ZCode, apps/zcode-cli 0.16.9; READ in its README, not run here) reads MCP servers from
|
|
1461
|
+
# ~/.zcode/cli/config.json `mcp.servers.<name>`; a stdio server {type, command, args, cwd, env, enabled, timeoutMs}
|
|
1462
|
+
# inherits ZCode's own environment, so the lane's variables arrive without an `env`. `features.mcp` false turns MCP off.
|
|
1463
|
+
def _zcode_ours(entry, command):
|
|
1464
|
+
"""A ZCode server entry that starts this lane-mcp (its `enabled` and `timeoutMs` are the person's)."""
|
|
1465
|
+
return isinstance(entry, dict) and entry.get('command') == command and not entry.get('args') \
|
|
1466
|
+
and entry.get('type', 'stdio') == 'stdio' and 'url' not in entry
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def zcode_mcp(text, command=MCP_COMMAND):
|
|
1470
|
+
"""~/.zcode/cli/config.json with `mcp.servers.lanes` = {"type": "stdio", "command": <command>} -> (new text, why).
|
|
1471
|
+
A file whose `features.mcp` is false (the person turned MCP off) is left alone, as is a `lanes` server that starts
|
|
1472
|
+
something else; an earlier install's lane-mcp at another path is replaced. The edit is one member, every other byte
|
|
1473
|
+
kept (the file may hold provider headers), and the result must parse back to the input plus that member."""
|
|
1474
|
+
what = '~/.zcode/cli/config.json'
|
|
1475
|
+
entry = {'type': 'stdio', 'command': command}
|
|
1476
|
+
if text is None or not text.strip():
|
|
1477
|
+
return json.dumps({'mcp': {'servers': {MCP_NAME: entry}}}, indent=2) + '\n', None
|
|
1478
|
+
doc = strict_json(text, what)
|
|
1479
|
+
if not isinstance(doc, dict):
|
|
1480
|
+
return text, f'{what} is not a JSON object — left alone'
|
|
1481
|
+
features = doc.get('features')
|
|
1482
|
+
if isinstance(features, dict) and features.get('mcp') is False:
|
|
1483
|
+
return text, f'features.mcp is false in {what} — MCP is off there; left alone'
|
|
1484
|
+
mcp = doc.get('mcp')
|
|
1485
|
+
servers = mcp.get('servers') if isinstance(mcp, dict) else None
|
|
1486
|
+
if (mcp is not None and not isinstance(mcp, dict)) or (servers is not None and not isinstance(servers, dict)):
|
|
1487
|
+
return text, f'mcp.servers in {what} is not an object — left alone'
|
|
1488
|
+
current = (servers or {}).get(MCP_NAME)
|
|
1489
|
+
if _zcode_ours(current, command):
|
|
1490
|
+
return text, None
|
|
1491
|
+
if current is not None and not _an_older_ours(current):
|
|
1492
|
+
shown = current.get('command') if isinstance(current, dict) else current
|
|
1493
|
+
return text, (f'mcp.servers.{MCP_NAME} in {what} is not the entry Lanes writes (command {shown!r}) — left '
|
|
1494
|
+
'alone (remove it to let Lanes add its own)')
|
|
1495
|
+
if mcp is None:
|
|
1496
|
+
after = set_member(text, [], 'mcp', {'servers': {MCP_NAME: entry}})
|
|
1497
|
+
elif servers is None:
|
|
1498
|
+
after = set_member(text, ['mcp'], 'servers', {MCP_NAME: entry})
|
|
1499
|
+
else:
|
|
1500
|
+
after = set_member(text, ['mcp', 'servers'], MCP_NAME, entry)
|
|
1501
|
+
if strict_json(after, what) != dict(doc, mcp=dict(mcp or {}, servers=dict(servers or {}, **{MCP_NAME: entry}))):
|
|
1502
|
+
return text, 'the entry could not be added without changing anything else — add it by hand'
|
|
1503
|
+
return after, None
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
MCP_EDITORS = {'claude': claude_mcp, 'codex': codex_mcp, 'kimi': kimi_mcp, 'grok': grok_mcp, 'grok-cli': grok_cli_mcp,
|
|
1507
|
+
'muse': muse_mcp, 'zcode': zcode_mcp}
|
|
1384
1508
|
|
|
1385
1509
|
|
|
1386
1510
|
def mcp_agents(home):
|
|
1387
|
-
"""The agents whose entry an install writes by default: MCP_ALWAYS, plus each of kimi, grok
|
|
1511
|
+
"""The agents whose entry an install writes by default: MCP_ALWAYS, plus each of kimi, grok, grok-cli, muse and zcode whose
|
|
1388
1512
|
home directory (MCP_HOMES) is a real directory under `home` — never a link (the host writes through the volume)."""
|
|
1389
1513
|
found = list(MCP_ALWAYS)
|
|
1390
1514
|
for agent in MCP_AGENTS:
|
|
@@ -18,10 +18,10 @@ and writes ~/.lz/state/throttle.json (the shape below, by rename) only when a va
|
|
|
18
18
|
since the file's `at` — `at` is the liveness heartbeat lanes-statusd ages:
|
|
19
19
|
|
|
20
20
|
{"v": 1, "at": <epoch s>, "up": bool, "tokened": bool,
|
|
21
|
-
"pools": {"claude"|"codex"|"kimi": {"configured", "usable", "cooling", "exhausted", "benched", "auth_failed",
|
|
21
|
+
"pools": {"claude"|"codex"|"kimi"|"muse-code"|"zai": {"configured", "usable", "cooling", "exhausted", "benched", "auth_failed",
|
|
22
22
|
"overflow_ready", "pressure_phase", "spill_fraction", "overflow_in_flight", "upstream_429_total",
|
|
23
23
|
"worst_7d", "worst_5h", "soonest_reset"}},
|
|
24
|
-
"overflow": {"claude"|"codex"|"kimi": {"spend_today_usd", "budget_usd", "fraction", "alert_level_pct"}},
|
|
24
|
+
"overflow": {"claude"|"codex"|"kimi"|"muse-code"|"zai": {"spend_today_usd", "budget_usd", "fraction", "alert_level_pct"}},
|
|
25
25
|
"error": null | "<why the daemon did not answer>"}
|
|
26
26
|
|
|
27
27
|
`cooling` is the remainder configured − usable − exhausted − auth_failed − disabled − billing/org-blocked − unhealthy,
|
|
@@ -36,7 +36,10 @@ POOLS are claude, codex and (W12, Lanes 1.5.0) kimi: the daemon's `/pools/:pool/
|
|
|
36
36
|
generic (`src/proxy.rs` `scoped_pool_state`), and a daemon with no Kimi account answers `/pools/kimi/ready` with
|
|
37
37
|
`configured_subscriptions: 0` — a pool with nothing configured, not a failure. Kimi has no paid overflow lane (the
|
|
38
38
|
daemon keeps its `api_overflow` at the default, and its /status still renders a zero overflow block), so only
|
|
39
|
-
MONEY_POOLS carry overflow money: `overflow.kimi` is always nulls.
|
|
39
|
+
MONEY_POOLS carry overflow money: `overflow.kimi` is always nulls. W13 (Lanes 1.6.0) adds Throttle's native `muse-code`
|
|
40
|
+
(Meta's Muse Code, listener 8097) and `zai` (Z.ai, listener 8098) subscription pools on the same generic routes; like
|
|
41
|
+
Kimi their overflow is refused by the daemon's config, so their overflow is always nulls too, and their accounts'
|
|
42
|
+
providers are `muse-code` and `zai` (Throttle's Provider names).
|
|
40
43
|
|
|
41
44
|
GET only, 3 s timeout, 1 MiB body cap, no redirect followed (one would carry the bearer elsewhere), no proxy from the
|
|
42
45
|
environment. Stdlib only. Exit: --once 0 when the daemon answered, 1 when not, 2 on a usage error; --loop runs until
|
|
@@ -62,12 +65,12 @@ TIMEOUT = 3 # seconds per GET
|
|
|
62
65
|
MAX_BODY = 1 << 20 # bytes: a bigger answer is not the daemon's
|
|
63
66
|
TOKEN_MAX = 4096 # bytes: a token file is one short line
|
|
64
67
|
DEFAULT_PORT = 19181
|
|
65
|
-
POOLS = ('claude', 'codex', 'kimi')
|
|
66
|
-
MONEY_POOLS = frozenset({'claude', 'codex'}) # the pools with a paid overflow lane; kimi
|
|
68
|
+
POOLS = ('claude', 'codex', 'kimi', 'muse-code', 'zai')
|
|
69
|
+
MONEY_POOLS = frozenset({'claude', 'codex'}) # the pools with a paid overflow lane; kimi/muse-code/zai money: null
|
|
67
70
|
# The gauge's numbers (src/proxy.rs render_metrics: 0=calm,1=spilling,2=elevated) as the words the daemon's own JSON
|
|
68
71
|
# uses for them (src/pressure.rs PressureSnapshot.phase: "closed" is calm), which economy.json and the sidebar read.
|
|
69
72
|
PHASES = {0: 'closed', 1: 'spilling', 2: 'elevated'}
|
|
70
|
-
SUBSCRIPTION_PROVIDERS = frozenset({'anthropic', 'openai', 'kimi'}) # src/proxy.rs is_subscription_provider
|
|
73
|
+
SUBSCRIPTION_PROVIDERS = frozenset({'anthropic', 'openai', 'kimi', 'muse-code', 'zai'}) # src/proxy.rs is_subscription_provider
|
|
71
74
|
SERIES = {'throttle_pool_spill_fraction': 'spill_fraction', 'throttle_overflow_in_flight': 'overflow_in_flight',
|
|
72
75
|
'throttle_upstream_429_total': 'upstream_429_total', 'throttle_pool_pressure_phase': 'pressure_phase'}
|
|
73
76
|
POOL_KEYS = ('configured', 'usable', 'cooling', 'exhausted', 'benched', 'auth_failed', 'overflow_ready',
|
|
@@ -70,14 +70,17 @@ for operations, the [runbook](RUNBOOK.md).
|
|
|
70
70
|
|
|
71
71
|
**The harness is observed (W12, Lanes 1.5.0; spec
|
|
72
72
|
`docs/superpowers/specs/2026-10-15-lanes-harness-observers-design.md`; DM-LANES-STATUS, DM-LANES-CONTAINER-STATUS).**
|
|
73
|
-
`agent.harness` is one of `claude | codex | kimi | grok | grok-cli | shell | unknown` (`
|
|
73
|
+
`agent.harness` is one of `claude | codex | kimi | grok | grok-cli | muse | zcode | shell | unknown` (W13 added `muse`
|
|
74
|
+
and `zcode`; `shell` is the walk's word for a
|
|
74
75
|
pane and never reaches a published `agent{}`), an OBSERVED fact read from
|
|
75
76
|
the pane's process tree, not a side effect of which title parser matched: `agent_state.harness_walk` reads
|
|
76
77
|
`/proc/<pane pid>/task/*/children` (the reads `pane_has_child` already makes) and each process's `comm` in the
|
|
77
78
|
container's /proc, breadth first from the pane's root down four levels and at most 64 processes, and the first
|
|
78
79
|
process whose comm is `claude`, `codex`, `kimi`/`kimi-code` or `grok` — or whose `/proc/<pid>/exe` holds
|
|
79
80
|
`/claude/versions/`, native Claude Code before 2.1.299 running with its version (`2.1.295`) as comm (DEF-1993) —
|
|
80
|
-
names it
|
|
81
|
+
names it; since W13 also `muse` (Muse Code's launcher script), `zcode-cli` (ZCode's process title), a comm starting
|
|
82
|
+
`muse-bin-` (MEASURED: `muse-bin-1.4.10`, the cut of `muse-bin-1.4.10-R6427.1`) or an exe whose file name does.
|
|
83
|
+
Codex runs as `bash` → `node` → `codex`, so it is found one level down; two `grok` binaries are told
|
|
81
84
|
apart by `/proc/<pid>/exe`: `…/downloads/grok-linux-<arch>` is Grok Build (the rule of
|
|
82
85
|
`harness_grok.harness_of_exe`; the container walks of `lane-signal` and `lane_core` match
|
|
83
86
|
`/downloads/grok-linux-x86_64`, the one arch measured), anything else grok-cli, an unreadable link `unknown`. Only
|
|
@@ -139,12 +142,33 @@ for operations, the [runbook](RUNBOOK.md).
|
|
|
139
142
|
it reaches `agent{}` only through the hook sidecar (DEF-1980). The Grok Build file shapes come from its source and
|
|
140
143
|
user guide; no logged-in session was measured yet (DEF-1981).
|
|
141
144
|
|
|
145
|
+
**Muse Code and ZCode** (W13, Lanes 1.6.0; spec
|
|
146
|
+
`docs/superpowers/specs/2026-10-16-lanes-harness-observers-muse-zcode-design.md`; `libexec/harness_muse.py`).
|
|
147
|
+
`agent_state.muse_sessions(root, panes, *, home, now)` forwards to `harness_muse.muse_sessions` (looked up at the
|
|
148
|
+
call like `harness_grok`); `statusd.agent_signals` passes {pane key: Muse pid} for every pane its walk observed as
|
|
149
|
+
`muse`. The join: the pane's Muse pid holds its session's `.session.lock` open (at most 512 fd links read; the
|
|
150
|
+
link must end `…/sessions/YYYY/MM/DD/<id>/.session.lock`, never a subagent's), the lock file names `pid=<pid>`
|
|
151
|
+
(a lock naming another pid is not this pane's); else the runtime registry's `process_generation_hint` `pid=<pid>`
|
|
152
|
+
and the newest 31 day directories searched for that id. Then the last 64 KiB of `session.jsonl`, whole lines only:
|
|
153
|
+
`runtime.user_intent.accepted` or run `started` → `working`; other in-run records keep it (a hook's run is no turn);
|
|
154
|
+
run `terminal` completed / cancelled → `needs_input`, failed → `errored`; `run_retracted` → `needs_input`; no turn
|
|
155
|
+
in a whole file → `idle`; `session.end` → no claim; a session older than an hour → no claim. The prompt the accepted
|
|
156
|
+
and `started` records carry is never read into a verdict (customer content). `session.name.changed` is the name (read from the log's first 32 KiB when the tail holds none: Muse names a session
|
|
157
|
+
near its start).
|
|
158
|
+
Verdict: `harness: muse`, `source: muse-session-file`, the session id, the Muse pid. `merge`'s order: sidecar >
|
|
159
|
+
Claude file > Kimi file > Muse file > Grok observer > Grok title > Muse title (`harness_muse.parse_muse_title`:
|
|
160
|
+
`⠙ <folder>` working, `<folder>` idle, `source: muse-title`) > Codex title > process. Muse and ZCode ring no bell
|
|
161
|
+
(`agent_state.BELL_LESS`): no rung turns their verdict into `done`. ZCode keeps sessions in SQLite and draws no
|
|
162
|
+
title: it reaches `agent{}` through its process (`zcode` / `idle`) and its hooks only. The approval records of
|
|
163
|
+
Muse's log were not captured (DEF-2057): the observer reads `needs_approval` only from a record kind that names an
|
|
164
|
+
approval or permission being asked.
|
|
165
|
+
|
|
142
166
|
What is published is not `merge`'s word itself but its debounced form, `agent_state.settle`, with three more
|
|
143
167
|
additive keys (`raw_status`, `raw_since`, `stable_since`): see [Attention queue](#attention-queue).
|
|
144
168
|
|
|
145
169
|
The hook sidecar is `~/.lz/state/hooks/<lane id>.json` in the container, written by `~/.lz/bin/lane-signal`
|
|
146
170
|
(Python 3 stdlib, installed by `lanes install-container`) when an opt-in hook fires (Claude Code, Codex, Kimi
|
|
147
|
-
Code, Grok Build, grok-cli): `{v: 1, lane, harness, status, since, at, event, pid, start, session}` — `since` when the status began (kept
|
|
171
|
+
Code, Grok Build, grok-cli, Muse Code, ZCode): `{v: 1, lane, harness, status, since, at, event, pid, start, session}` — `since` when the status began (kept
|
|
148
172
|
while the same status is re-signalled), `at` the last event, `pid`/`start` the agent process and its
|
|
149
173
|
starttime — plus `name`, the agent's own note when it reported one (`lanes_signal`, LNS-35: kept while the status
|
|
150
174
|
repeats, dropped when it changes). A report (`event: lanes_signal`) stands against the same agent's hooks — the
|
|
@@ -161,14 +185,19 @@ for operations, the [runbook](RUNBOOK.md).
|
|
|
161
185
|
container `lane ls` read (DEF-1572). A zmx lane's sidecar is therefore named by its zmx session, not by the
|
|
162
186
|
`<ws>--z-<name>` id statusd publishes, and does not reach that lane's `agent{}` yet — the zmx lane's `agent` key
|
|
163
187
|
stays lane-run's record (DEF-1573). A published sidecar adds `event` to `agent{}` and has `source: hook`. Hooks are
|
|
164
|
-
never installed implicitly: `lanes hooks install --agent claude|codex|kimi|grok|grok-cli --container <name>`
|
|
188
|
+
never installed implicitly: `lanes hooks install --agent claude|codex|kimi|grok|grok-cli|muse|zcode --container <name>`
|
|
165
189
|
prints the merge and writes only with `--write` (RUNBOOK "Installing agent hooks" and "Harnesses").
|
|
166
190
|
|
|
167
191
|
**The container side** (W12 Track C). `lane-signal`'s default harness is `--harness`, else `LANE_HARNESS`, else
|
|
168
192
|
the parent processes' (`comm` in the same set, a `grok` split by its exe, a native Claude Code file by its exe),
|
|
169
193
|
else `unknown` — never `codex`; `lane-mcp` names the client's harness from its MCP client name (`claude`, `codex`,
|
|
170
|
-
`kimi-code`/`kimi`, `grok-shell-*` → `grok`, `grok-cli*` → `grok-cli`
|
|
171
|
-
|
|
194
|
+
`kimi-code`/`kimi`, `grok-shell-*` → `grok`, `grok-cli*` → `grok-cli`; W13: `tbh` → `muse`, `zcode*` → `zcode`),
|
|
195
|
+
else the parent rule. W13: when a hook arrives with neither `LANE_ID` nor `TMUX` (Muse Code clears its hooks'
|
|
196
|
+
environment), `lane-signal` reads `LANE_ID`, `LANE_WS`, `TMUX` and `TMUX_PANE` from the agent's own
|
|
197
|
+
`/proc/<pid>/environ` — only for a pid that is a known harness, bounded, never through a link: the rule of
|
|
198
|
+
`lane_core.parent_environ`, pinned equal by `test_harness_w13.SignalIdentityFallback` (DEF-2031); a
|
|
199
|
+
`PostToolUseFailure` with `is_interrupt: true` is `needs_input` (ZCode runs no Stop after an interrupt, DEF-2091).
|
|
200
|
+
The per-harness operator steps are RUNBOOK "Harnesses"; what a person sees, the user guide's "Harnesses".
|
|
172
201
|
|
|
173
202
|
**Fleet and Throttle facts** (W11, spec `docs/superpowers/specs/2026-10-11-lanes-fleet-dashboard-design.md` §2;
|
|
174
203
|
DM-LANES-THROTTLE-PULSE, DM-LANES-FLEET). Two additive `status.json` keys feed the sidebar's fleet block and
|
|
@@ -178,15 +207,16 @@ for operations, the [runbook](RUNBOOK.md).
|
|
|
178
207
|
its container's `127.0.0.1` (DEF-1525), so the host cannot poll it. `~/.lz/bin/throttle-pulse` (a contract file,
|
|
179
208
|
Python 3 stdlib) runs as a loop only in the daemon's container — site.conf `LANES_THROTTLE_CONTAINER`, else the
|
|
180
209
|
container of the registry's `tools--throttle-monitor` tab — where `lanes install-container` starts it, once, as
|
|
181
|
-
`tmux -L lanes-pulse` session `pulse`. Every 10 s it GETs the open `/pools/{claude,codex,kimi}/ready` and
|
|
182
|
-
`/metrics`, and the guarded `/pools/{claude,codex,kimi}/status` only with the bearer the operator adopted into
|
|
210
|
+
`tmux -L lanes-pulse` session `pulse`. Every 10 s it GETs the open `/pools/{claude,codex,kimi,muse-code,zai}/ready` and
|
|
211
|
+
`/metrics`, and the guarded `/pools/{claude,codex,kimi,muse-code,zai}/status` only with the bearer the operator adopted into
|
|
183
212
|
`~/.lz/throttle-token` (`lanes throttle adopt-token`; the control port has no loopback exemption, DEF-1930), and
|
|
184
213
|
writes `~/.lz/state/throttle.json` by rename when a value changed or 60 s passed — never anything else, never a
|
|
185
214
|
POST (RUNBOOK "Throttle pulse"). The kimi pool (W12) rides the daemon's generic pool routes (`scoped_pool_state`):
|
|
186
215
|
a daemon with no Kimi account reports it with nothing configured, and since Kimi has no paid overflow lane its
|
|
187
|
-
`overflow` is always null (`MONEY_POOLS` is claude and codex).
|
|
188
|
-
|
|
189
|
-
|
|
216
|
+
`overflow` is always null (`MONEY_POOLS` is claude and codex). W13 adds Throttle's native `muse-code` and `zai`
|
|
217
|
+
pools the same way (no paid overflow either: their overflow is null too). The sidebar draws one provider row per
|
|
218
|
+
pool — `◆ anthropic`, `◆ openai`, `◆ moonshot`, `◆ meta`, `◆ z.ai` — so the full block is a rule and eight rows
|
|
219
|
+
from 27 pane rows (`FLEET_FULL`), and the three-row form shows the pool with the smallest usable share, a pool with nothing
|
|
190
220
|
configured never (DEF-2002). There is no Grok row: Throttle has no xAI pool.
|
|
191
221
|
|
|
192
222
|
*`throttle`.* On every cycle statusd copies that file into `status.json`. It looks the daemon's container up again
|
|
@@ -711,6 +741,23 @@ identity so folder permissions work. On Linux it is a systemd user service that
|
|
|
711
741
|
user manager holds, looked up before every login, so an agent a desktop session hands over after boot is used
|
|
712
742
|
without a restart. The installer's remembered `--no-uploads` / `--no-desktop` remove what an earlier install added.
|
|
713
743
|
|
|
744
|
+
Desktop `lanes attach` execs `libexec/attach.py`, a PTY supervisor for exactly one Zellij client. It relays raw
|
|
745
|
+
input and output with nonblocking writes, forwards the terminal size, and restores terminal modes on exit.
|
|
746
|
+
`OUTPUT_LIMIT` (1 MiB) and `OUTPUT_AGE` (5 s) bound output waiting for the SSH terminal; no output means no timeout.
|
|
747
|
+
Crossing either bound terminates only its child, with `STOP_GRACE` (1 s) before KILL, and exits 1 for the existing
|
|
748
|
+
`lanes-launch` reconnect loop. The view server, its panes and their tmux/zmx holders remain alive. The existing
|
|
749
|
+
`clients/<device>.<client>.pid` now names the supervisor; its argv still carries the exact Zellij attach command,
|
|
750
|
+
so stale-predecessor cleanup reaches the supervisor and its owned child. No new durable state is introduced.
|
|
751
|
+
|
|
752
|
+
This closes the desktop failure observed on Anarchy on 2026-10-16 (DEF-1965). In the pinned Zellij 0.45.1,
|
|
753
|
+
[`ClientSender`](https://github.com/zellij-org/zellij/blob/v0.45.1/zellij-server/src/os_input_output.rs) removes a
|
|
754
|
+
client when its 5,000-message queue fills, but sends Exit only after draining queued messages. The
|
|
755
|
+
[`input handler`](https://github.com/zellij-org/zellij/blob/v0.45.1/zellij-client/src/input_handler.rs) can then wait
|
|
756
|
+
indefinitely for an action acknowledgement. The transport remains alive while the display cannot interact.
|
|
757
|
+
The supervisor bounds terminal backpressure before that can strand an attachment; it does not patch upstream
|
|
758
|
+
Zellij or cover its separate browser transport. `tests/attach_backpressure.py` proves the unread-terminal control,
|
|
759
|
+
bounded disconnect, holder survival and keyboard switching after reattachment with the installed pin.
|
|
760
|
+
|
|
714
761
|
The sidebar sends an upload request naming paths, workspace and device. The client checks its folder allowlist,
|
|
715
762
|
streams an archive over SSH, and reports original errors and progress. The host extracts data into that
|
|
716
763
|
workspace's `uploads/` folder without overwriting files; an optional paste places the resulting paths in the
|