@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
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""Muse Code observer for agent{} (Lanes W13; spec
|
|
2
|
+
docs/superpowers/specs/2026-10-16-lanes-harness-observers-muse-zcode-design.md §3). Pure reads through the container
|
|
3
|
+
root statusd already holds (`/proc/<container init pid>/root`): no subprocess, no write, nothing done at import. Meta's
|
|
4
|
+
terms forbid reverse engineering the binary: every fact below was read from the files and processes a normal run makes
|
|
5
|
+
(`muse --provider echo` in a scratch HOME) or from the read-session skill Muse ships.
|
|
6
|
+
|
|
7
|
+
Muse Code (Meta, `muse` 1.4.10-R6427.1) runs as ONE process: the launcher `~/.local/bin/muse` (a script) execs
|
|
8
|
+
`~/.local/bin/muse-bin-<version>`, whose comm is the 15-byte cut `muse-bin-1.4.10` [M]. Per session it keeps:
|
|
9
|
+
|
|
10
|
+
* the event log `${XDG_DATA_HOME:-~/.local/share}/muse/sessions/YYYY/MM/DD/<session id>/session.jsonl` [D read-session
|
|
11
|
+
SKILL.md, M]: one JSON record per line, `recorded_at` in epoch MICROseconds, `payload_type` naming the record. The
|
|
12
|
+
records read here: `runtime.session` with `payload.kind` `run` and `payload.event.kind` `started` (a turn opens; its
|
|
13
|
+
`prompt` is the person's text — CUSTOMER CONTENT, never read into a verdict), the in-run kinds (model_*, assistant_*,
|
|
14
|
+
task_stream_linked, context_block_*, … and `payload.kind` `task` records), `terminal` with `terminal` completed |
|
|
15
|
+
failed | cancelled (the turn ends), `run_retracted` (a cancelled turn's prompt restored); `session.name.changed`
|
|
16
|
+
(`payload.new_name`, the session's name: automatic, two words such as `ash-megrez`, or the person's /rename);
|
|
17
|
+
`session.started`; and `session.end` when the process exits [M]. A `task failed` record also appears INSIDE a
|
|
18
|
+
completed turn (an internal task, MEASURED 2026-10-16): only the run's `terminal` says how a turn ended.
|
|
19
|
+
* `.session.lock` beside it, held OPEN by the live process (two fds) and holding `pid=<pid>\\nhost=<host>` [M]: the
|
|
20
|
+
pid <-> session join (`lock_session`, at most FD_MAX fd links read). Fallback: the live registry
|
|
21
|
+
`…/muse/runtime/muse/sessions/<session id>.json` {session_id, session_name, workspace_label,
|
|
22
|
+
process_generation_hint: "pid=<pid>", …}, deleted when the process exits [M] (`registry_session`).
|
|
23
|
+
* The pane title [M]: `⠙ <workspace basename>` (a braille spinner) while a turn runs, `<workspace basename>` between
|
|
24
|
+
turns, a single space when it exits. Muse rings NO bell [M], so nothing here (and nothing in agent_state.merge for a
|
|
25
|
+
Muse pane) turns a verdict into `done` by the bell.
|
|
26
|
+
|
|
27
|
+
`muse_sessions(root, panes, home)` -> {pane key: verdict}, the dict `agent_state.merge` takes from a session file:
|
|
28
|
+
{harness: 'muse', status, name, session_id, pid, since, source: 'muse-session-file', event}. `session_status` maps the
|
|
29
|
+
log's tail (its last TAIL_BYTES, whole lines only): `started` -> working (since: the turn's start); in-run records ->
|
|
30
|
+
working; `terminal` completed / cancelled -> needs_input, failed -> errored; `run_retracted` -> needs_input; no turn in
|
|
31
|
+
a log read from its first byte -> idle; `session.end` -> no claim (the process exited; the title and the process walk
|
|
32
|
+
decide). A log whose newest record and file are older than STALE makes no claim either.
|
|
33
|
+
|
|
34
|
+
NOT measured (needs_operator_capture, DEF-2057): the records an approval leaves. Muse 1.4.10 runs in an Auto-review
|
|
35
|
+
permission mode by default (its footer says so, and a session directory gains `approval-review/`); a turn that waits
|
|
36
|
+
for the person was not captured. APPROVAL_ASKED / APPROVAL_ANSWERED are a conservative guess at the kind names that
|
|
37
|
+
reads `needs_approval` only for a record whose kind says it, never for any other; the hooks' PermissionRequest /
|
|
38
|
+
Notification (`lanes hooks install --agent muse`) are the primary approval signal.
|
|
39
|
+
|
|
40
|
+
Every directory is opened without following a link (a container owns them), a FIFO is never opened blocking, and
|
|
41
|
+
nothing larger than its bound is read.
|
|
42
|
+
"""
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
import stat
|
|
47
|
+
import time
|
|
48
|
+
|
|
49
|
+
HARNESS = 'muse'
|
|
50
|
+
CONTAINER_HOME = '/home/coder'
|
|
51
|
+
DATA_HOME = ('.local', 'share', 'muse') # ${XDG_DATA_HOME:-~/.local/share}/muse, the default
|
|
52
|
+
SESSIONS = 'sessions'
|
|
53
|
+
REGISTRY = ('runtime', 'muse', 'sessions') # under DATA_HOME: one <session id>.json per live process
|
|
54
|
+
LOG = 'session.jsonl'
|
|
55
|
+
LOCK = '.session.lock'
|
|
56
|
+
SUBAGENT = 'subagent' # sessions/…/<id>/subagent/<child id>/ — a child's own log, never a pane's
|
|
57
|
+
|
|
58
|
+
TAIL_BYTES = 64 << 10 # session.jsonl read from its end per pane per cycle (MEASURED: 113 KB after one echo turn)
|
|
59
|
+
# The session's automatic name is written near the log's start (MEASURED over 13 echo sessions: at byte 13 440–21 730),
|
|
60
|
+
# so after one turn it is no longer in the tail: when the tail names none, the first HEAD_BYTES are read for it.
|
|
61
|
+
HEAD_BYTES = 32 << 10
|
|
62
|
+
JSON_MAX = 64 << 10 # a registry file (MEASURED: 235 B)
|
|
63
|
+
LOCK_MAX = 256 # .session.lock (MEASURED: 21 B, `pid=<pid>\nhost=<host>\n`)
|
|
64
|
+
FD_MAX = 512 # fd links read per Muse process for the lock (MEASURED: 47 open fds)
|
|
65
|
+
REGISTRY_MAX = 256 # registry files looked at per container per cycle
|
|
66
|
+
DAY_DIRS = 31 # newest sessions/YYYY/MM/DD directories searched for a registry session id
|
|
67
|
+
STALE = 3600 # seconds: an older session makes no claim
|
|
68
|
+
NAME_MAX = 120
|
|
69
|
+
|
|
70
|
+
SPINNER = frozenset('⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏') # MEASURED in the title: ⠙ ⠸ ⠼ ⠦ ⠧ (the rest of the braille set Muse may draw)
|
|
71
|
+
SHELL_TITLES = frozenset(('', 'bash', 'sh', 'zsh', 'fish'))
|
|
72
|
+
SOURCE_FILE = 'muse-session-file'
|
|
73
|
+
SOURCE_TITLE = 'muse-title'
|
|
74
|
+
TERMINAL = {'completed': 'needs_input', 'cancelled': 'needs_input', 'failed': 'errored'}
|
|
75
|
+
HOOK_KINDS = frozenset(('hook_run_started', 'hook_run_terminal')) # a hook's run, before and after a turn: not a turn
|
|
76
|
+
# The person's prompt accepted: the turn opens here, before its UserPromptSubmit hook and its `run started` (MEASURED:
|
|
77
|
+
# 1 s apart with a 1 s hook). The record carries the prompt's text (customer content): only its type is read.
|
|
78
|
+
INTENT = 'runtime.user_intent.accepted'
|
|
79
|
+
# [U] needs_operator_capture (DEF-2057): no approval was captured; these read `needs_approval` only for a kind that
|
|
80
|
+
# names an approval or permission being asked, and clear it on one that names the answer (or the turn's end).
|
|
81
|
+
APPROVAL_ASKED = re.compile(r'(?:^|[._])(?:approval|permission)[._](?:requested|pending|required|prompted|prompt)\Z')
|
|
82
|
+
APPROVAL_ANSWERED = re.compile(
|
|
83
|
+
r'(?:^|[._])(?:approval|permission)[._](?:resolved|granted|denied|decided|answered|approved|rejected)\Z')
|
|
84
|
+
SESSION_ID = re.compile(r'[0-9A-Za-z][0-9A-Za-z_-]{7,127}\Z')
|
|
85
|
+
DAY_PART = (re.compile(r'\d{4}\Z'), re.compile(r'\d{2}\Z'), re.compile(r'\d{2}\Z'))
|
|
86
|
+
HINT = re.compile(r'pid=(\d+)\Z')
|
|
87
|
+
LOCK_PID = re.compile(rb'(?m)^pid=(\d+)\s*$')
|
|
88
|
+
|
|
89
|
+
_DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_CLOEXEC', 0)
|
|
90
|
+
_FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------------------------------------ pure parsers
|
|
94
|
+
|
|
95
|
+
def parse_muse_title(title):
|
|
96
|
+
"""A Muse pane title -> {'harness': 'muse', 'status', 'name'}, or None (no title, a shell's, the exit's single
|
|
97
|
+
space). `⠙ <workspace>` (a braille spinner, a space, the workspace's name) -> working; any other title -> idle, its
|
|
98
|
+
text the name. Called only for a pane whose OBSERVED harness is muse: Codex draws a spinner too."""
|
|
99
|
+
text = title.strip() if isinstance(title, str) else ''
|
|
100
|
+
if text in SHELL_TITLES:
|
|
101
|
+
return None
|
|
102
|
+
if text[0] in SPINNER and (len(text) == 1 or text[1] == ' '):
|
|
103
|
+
return {'harness': HARNESS, 'status': 'working', 'name': _printable(text[1:])}
|
|
104
|
+
return {'harness': HARNESS, 'status': 'idle', 'name': _printable(text)}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _printable(text):
|
|
108
|
+
if not isinstance(text, str):
|
|
109
|
+
return ''
|
|
110
|
+
return ''.join(ch for ch in text if ch.isprintable()).strip()[:NAME_MAX]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_records(lines):
|
|
114
|
+
"""The record dicts of session.jsonl lines (bytes or str); a line that is not one JSON object is skipped."""
|
|
115
|
+
records = []
|
|
116
|
+
for line in lines:
|
|
117
|
+
try:
|
|
118
|
+
record = json.loads(line.decode('utf-8') if isinstance(line, bytes) else line)
|
|
119
|
+
except (UnicodeDecodeError, ValueError):
|
|
120
|
+
continue
|
|
121
|
+
if isinstance(record, dict):
|
|
122
|
+
records.append(record)
|
|
123
|
+
return records
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _seconds(record):
|
|
127
|
+
"""Epoch seconds of a record's `recorded_at` (epoch MICROseconds), or None."""
|
|
128
|
+
value = record.get('recorded_at')
|
|
129
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
130
|
+
return None
|
|
131
|
+
return int(value // 1_000_000)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _event(record):
|
|
135
|
+
"""(payload kind, event kind) of a `runtime.session` record, else (None, None)."""
|
|
136
|
+
payload = record.get('payload')
|
|
137
|
+
if record.get('payload_type') != 'runtime.session' or not isinstance(payload, dict):
|
|
138
|
+
return None, None
|
|
139
|
+
event = payload.get('event')
|
|
140
|
+
kind = event.get('kind') if isinstance(event, dict) else None
|
|
141
|
+
return payload.get('kind'), (kind if isinstance(kind, str) else None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def session_status(records, whole=True):
|
|
145
|
+
"""{'status', 'since', 'event', 'name', 'ended', 'activity'} of one session from its log's records in file order,
|
|
146
|
+
or None when they say nothing about a turn (the tail window holds none; the caller falls back to the title).
|
|
147
|
+
|
|
148
|
+
`ended`: the last session boundary is `session.end` (a later `session.started` clears it). `name`: the last
|
|
149
|
+
`session.name.changed` new_name. `activity`: the newest `recorded_at`, epoch seconds (None when no record has one).
|
|
150
|
+
The status — the last turn boundary wins: the prompt accepted (INTENT) or run `started` -> working (since: the
|
|
151
|
+
first of them); a run `terminal` by its
|
|
152
|
+
`terminal` (TERMINAL; an unknown word still ends the turn: needs_input); `run_retracted` -> needs_input. Inside a
|
|
153
|
+
turn, a record that names an approval being asked (APPROVAL_ASKED, needs_operator_capture) -> needs_approval until
|
|
154
|
+
one names its answer (APPROVAL_ANSWERED) or the turn ends; any other in-run record (`runtime.session` of kind `run`
|
|
155
|
+
or `task`, but not a hook's run, HOOK_KINDS) is working, and when the window starts mid-turn (no boundary seen, not
|
|
156
|
+
the whole file) it alone says working. No turn in a whole file: idle (a session nobody has asked anything yet).
|
|
157
|
+
Never reads `prompt` or any text of a record: only kinds, names and times."""
|
|
158
|
+
status = since = event = None
|
|
159
|
+
name, ended, activity = '', False, None
|
|
160
|
+
in_turn = None # None: no turn boundary seen in this window
|
|
161
|
+
asking = False
|
|
162
|
+
for record in records:
|
|
163
|
+
at = _seconds(record)
|
|
164
|
+
if at is not None:
|
|
165
|
+
activity = at if activity is None else max(activity, at)
|
|
166
|
+
kind = record.get('payload_type')
|
|
167
|
+
payload = record.get('payload') if isinstance(record.get('payload'), dict) else {}
|
|
168
|
+
if kind == 'session.end':
|
|
169
|
+
ended = True
|
|
170
|
+
continue
|
|
171
|
+
if kind == 'session.started':
|
|
172
|
+
ended = False
|
|
173
|
+
continue
|
|
174
|
+
if kind == 'session.name.changed':
|
|
175
|
+
found = _printable(payload.get('new_name'))
|
|
176
|
+
if found:
|
|
177
|
+
name = found
|
|
178
|
+
continue
|
|
179
|
+
group, word = _event(record)
|
|
180
|
+
new = None
|
|
181
|
+
if kind == INTENT or (group == 'run' and word == 'started'):
|
|
182
|
+
if in_turn is not True:
|
|
183
|
+
asking = False
|
|
184
|
+
in_turn, new = True, ('needs_approval' if asking else 'working')
|
|
185
|
+
elif group == 'run' and word == 'terminal':
|
|
186
|
+
in_turn, asking = False, False
|
|
187
|
+
result = payload['event'].get('terminal')
|
|
188
|
+
new = TERMINAL.get(result, 'needs_input') if isinstance(result, str) else 'needs_input'
|
|
189
|
+
elif group == 'run' and word == 'run_retracted':
|
|
190
|
+
in_turn, asking, new = False, False, 'needs_input'
|
|
191
|
+
elif in_turn is False or (in_turn is None and whole):
|
|
192
|
+
continue # between turns (a hook, a resource sample) or before the first: says nothing
|
|
193
|
+
elif word and APPROVAL_ASKED.search(word):
|
|
194
|
+
asking, new = True, 'needs_approval'
|
|
195
|
+
elif word and APPROVAL_ANSWERED.search(word):
|
|
196
|
+
asking, new = False, 'working'
|
|
197
|
+
elif group in ('run', 'task') and word not in HOOK_KINDS:
|
|
198
|
+
new = None if asking else 'working'
|
|
199
|
+
if new is None:
|
|
200
|
+
continue
|
|
201
|
+
if new != status:
|
|
202
|
+
since, event = at, (word or kind)
|
|
203
|
+
status = new
|
|
204
|
+
if status is None and whole:
|
|
205
|
+
status, event = 'idle', 'session.started'
|
|
206
|
+
if status is None:
|
|
207
|
+
return None if not (ended or name) else {'status': None, 'since': None, 'event': None, 'name': name,
|
|
208
|
+
'ended': ended, 'activity': activity}
|
|
209
|
+
return {'status': status, 'since': since, 'event': event, 'name': name, 'ended': ended, 'activity': activity}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_registry(data):
|
|
213
|
+
"""(session id, pid) of a runtime registry file's bytes, or None: `session_id` and `process_generation_hint`
|
|
214
|
+
`pid=<pid>` [M]."""
|
|
215
|
+
try:
|
|
216
|
+
doc = json.loads(data.decode('utf-8')) if data else None
|
|
217
|
+
except (UnicodeDecodeError, ValueError):
|
|
218
|
+
return None
|
|
219
|
+
if not isinstance(doc, dict):
|
|
220
|
+
return None
|
|
221
|
+
sid, hint = doc.get('session_id'), doc.get('process_generation_hint')
|
|
222
|
+
found = HINT.match(hint) if isinstance(hint, str) else None
|
|
223
|
+
if not isinstance(sid, str) or not SESSION_ID.match(sid) or not found:
|
|
224
|
+
return None
|
|
225
|
+
return sid, int(found.group(1))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def lock_parts(link):
|
|
229
|
+
"""The container path components of a session directory from an fd link to its `.session.lock`, or None: the
|
|
230
|
+
link must end `…/sessions/YYYY/MM/DD/<session id>/.session.lock` (a subagent's lock is not the pane's session)."""
|
|
231
|
+
if not isinstance(link, str) or not link.startswith('/') or not link.endswith('/' + LOCK):
|
|
232
|
+
return None
|
|
233
|
+
parts = [part for part in link.split('/') if part]
|
|
234
|
+
if len(parts) < 6 or not all(_leaf(part) for part in parts):
|
|
235
|
+
return None
|
|
236
|
+
day, sid = parts[-5:-2], parts[-2]
|
|
237
|
+
if parts[-6] != SESSIONS or not SESSION_ID.match(sid) or not all(p.match(d) for p, d in zip(DAY_PART, day)):
|
|
238
|
+
return None
|
|
239
|
+
return parts[:-1]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------------------------------------ bounded reads
|
|
243
|
+
|
|
244
|
+
def _leaf(name):
|
|
245
|
+
return isinstance(name, str) and bool(name) and '/' not in name and '\0' not in name and name not in ('.', '..')
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _open_dir_at(dir_fd, parts):
|
|
249
|
+
"""A new fd of dir_fd/<parts…>, every component a REAL directory opened O_NOFOLLOW."""
|
|
250
|
+
current = os.dup(dir_fd)
|
|
251
|
+
try:
|
|
252
|
+
for part in parts:
|
|
253
|
+
if not _leaf(part):
|
|
254
|
+
raise OSError(f'not one path component: {part!r}')
|
|
255
|
+
child = os.open(part, _DIR_FLAGS | os.O_NOFOLLOW, dir_fd=current)
|
|
256
|
+
os.close(current)
|
|
257
|
+
current = child
|
|
258
|
+
except BaseException:
|
|
259
|
+
os.close(current)
|
|
260
|
+
raise
|
|
261
|
+
return current
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _pread_all(fd, offset, count):
|
|
265
|
+
chunks, size = [], 0
|
|
266
|
+
while size < count:
|
|
267
|
+
chunk = os.pread(fd, count - size, offset + size)
|
|
268
|
+
if not chunk:
|
|
269
|
+
break
|
|
270
|
+
chunks.append(chunk)
|
|
271
|
+
size += len(chunk)
|
|
272
|
+
return b''.join(chunks)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def read_regular_at(dir_fd, name, limit):
|
|
276
|
+
"""The bytes of `name` in `dir_fd` when it is a regular file of at most `limit` bytes, else None."""
|
|
277
|
+
if dir_fd is None or not _leaf(name):
|
|
278
|
+
return None
|
|
279
|
+
try:
|
|
280
|
+
fd = os.open(name, _FILE_FLAGS, dir_fd=dir_fd)
|
|
281
|
+
except OSError:
|
|
282
|
+
return None
|
|
283
|
+
try:
|
|
284
|
+
info = os.fstat(fd)
|
|
285
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
|
|
286
|
+
return None
|
|
287
|
+
data = _pread_all(fd, 0, limit + 1)
|
|
288
|
+
return data if len(data) <= limit else None
|
|
289
|
+
except OSError:
|
|
290
|
+
return None
|
|
291
|
+
finally:
|
|
292
|
+
os.close(fd)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def read_tail_lines(dir_fd, name, limit=None):
|
|
296
|
+
"""(lines, whole, mtime) — the complete lines in the last `limit` bytes of `name`, whether the window reached the
|
|
297
|
+
file's first byte, and the file's mtime — or None (absent, a link, a FIFO, unreadable). A first line cut by the
|
|
298
|
+
window and a last line still being written are dropped, never parsed."""
|
|
299
|
+
limit = TAIL_BYTES if limit is None else limit
|
|
300
|
+
if dir_fd is None or not _leaf(name):
|
|
301
|
+
return None
|
|
302
|
+
try:
|
|
303
|
+
fd = os.open(name, _FILE_FLAGS, dir_fd=dir_fd)
|
|
304
|
+
except OSError:
|
|
305
|
+
return None
|
|
306
|
+
try:
|
|
307
|
+
info = os.fstat(fd)
|
|
308
|
+
if not stat.S_ISREG(info.st_mode):
|
|
309
|
+
return None
|
|
310
|
+
size = info.st_size
|
|
311
|
+
start = max(0, size - limit)
|
|
312
|
+
if start:
|
|
313
|
+
data = _pread_all(fd, start - 1, size - start + 1)
|
|
314
|
+
if data[:1] == b'\n':
|
|
315
|
+
data = data[1:]
|
|
316
|
+
else:
|
|
317
|
+
cut = data.find(b'\n')
|
|
318
|
+
data = b'' if cut < 0 else data[cut + 1:]
|
|
319
|
+
else:
|
|
320
|
+
data = _pread_all(fd, 0, size)
|
|
321
|
+
except OSError:
|
|
322
|
+
return None
|
|
323
|
+
finally:
|
|
324
|
+
os.close(fd)
|
|
325
|
+
if not data.endswith(b'\n'):
|
|
326
|
+
cut = data.rfind(b'\n')
|
|
327
|
+
data = b'' if cut < 0 else data[:cut + 1]
|
|
328
|
+
return [line for line in data.split(b'\n') if line.strip()], start == 0, info.st_mtime
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def read_head_lines(dir_fd, name, limit=None):
|
|
332
|
+
"""The complete lines in the first `limit` (default HEAD_BYTES) bytes of `name` (a last line cut by the window
|
|
333
|
+
dropped), or None."""
|
|
334
|
+
limit = HEAD_BYTES if limit is None else limit
|
|
335
|
+
if dir_fd is None or not _leaf(name):
|
|
336
|
+
return None
|
|
337
|
+
try:
|
|
338
|
+
fd = os.open(name, _FILE_FLAGS, dir_fd=dir_fd)
|
|
339
|
+
except OSError:
|
|
340
|
+
return None
|
|
341
|
+
try:
|
|
342
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
343
|
+
return None
|
|
344
|
+
data = _pread_all(fd, 0, limit)
|
|
345
|
+
except OSError:
|
|
346
|
+
return None
|
|
347
|
+
finally:
|
|
348
|
+
os.close(fd)
|
|
349
|
+
cut = data.rfind(b'\n')
|
|
350
|
+
return [line for line in data[:cut + 1].split(b'\n') if line.strip()] if cut >= 0 else []
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def head_name(lines):
|
|
354
|
+
"""The last `session.name.changed` name among records (the head of a log), or ''."""
|
|
355
|
+
found = ''
|
|
356
|
+
for record in parse_records(lines or ()):
|
|
357
|
+
payload = record.get('payload')
|
|
358
|
+
if record.get('payload_type') == 'session.name.changed' and isinstance(payload, dict):
|
|
359
|
+
found = _printable(payload.get('new_name')) or found
|
|
360
|
+
return found
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _proc_link(proc_root, pid, name):
|
|
364
|
+
try:
|
|
365
|
+
return os.readlink(os.path.join(proc_root, str(pid), name))
|
|
366
|
+
except OSError:
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def lock_session(proc_root, pid, limit=None):
|
|
371
|
+
"""The container path components of the session directory whose `.session.lock` process `pid` holds open (its fd
|
|
372
|
+
links, the lowest `limit` — default FD_MAX — read), or None."""
|
|
373
|
+
limit = FD_MAX if limit is None else limit
|
|
374
|
+
fd_dir = os.path.join(proc_root, str(int(pid)), 'fd')
|
|
375
|
+
try:
|
|
376
|
+
names = os.listdir(fd_dir)
|
|
377
|
+
except OSError:
|
|
378
|
+
return None
|
|
379
|
+
for name in sorted((n for n in names if n.isdigit()), key=int)[:limit]:
|
|
380
|
+
parts = lock_parts(_proc_link(proc_root, pid, os.path.join('fd', name)))
|
|
381
|
+
if parts:
|
|
382
|
+
return parts
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _lock_pid(session_fd):
|
|
387
|
+
"""The pid `.session.lock` names, or None when it names none (an older Muse, an unreadable file)."""
|
|
388
|
+
data = read_regular_at(session_fd, LOCK, LOCK_MAX)
|
|
389
|
+
found = LOCK_PID.search(data) if data else None
|
|
390
|
+
return int(found.group(1)) if found else None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------------------------------------------ the observer
|
|
394
|
+
|
|
395
|
+
class _MuseHome:
|
|
396
|
+
"""One container's Muse data directory for one call: the root fd, the registry (read at most once)."""
|
|
397
|
+
|
|
398
|
+
def __init__(self, root, home):
|
|
399
|
+
self.root = str(root)
|
|
400
|
+
self.home = [part for part in str(home).split('/') if part]
|
|
401
|
+
self._registry = None
|
|
402
|
+
|
|
403
|
+
def _open(self, parts):
|
|
404
|
+
base = os.open(self.root, _DIR_FLAGS)
|
|
405
|
+
try:
|
|
406
|
+
return _open_dir_at(base, parts)
|
|
407
|
+
finally:
|
|
408
|
+
os.close(base)
|
|
409
|
+
|
|
410
|
+
def registry(self):
|
|
411
|
+
"""{pid: session id} of the live registry's files (at most REGISTRY_MAX)."""
|
|
412
|
+
if self._registry is None:
|
|
413
|
+
self._registry = {}
|
|
414
|
+
try:
|
|
415
|
+
fd = self._open(self.home + list(DATA_HOME) + list(REGISTRY))
|
|
416
|
+
except OSError:
|
|
417
|
+
return self._registry
|
|
418
|
+
try:
|
|
419
|
+
names = sorted(n for n in os.listdir(fd) if n.endswith('.json'))[:REGISTRY_MAX]
|
|
420
|
+
for name in names:
|
|
421
|
+
found = parse_registry(read_regular_at(fd, name, JSON_MAX))
|
|
422
|
+
if found and found[1] not in self._registry:
|
|
423
|
+
self._registry[found[1]] = found[0]
|
|
424
|
+
except OSError:
|
|
425
|
+
pass
|
|
426
|
+
finally:
|
|
427
|
+
os.close(fd)
|
|
428
|
+
return self._registry
|
|
429
|
+
|
|
430
|
+
def session_dir(self, parts):
|
|
431
|
+
try:
|
|
432
|
+
return self._open(parts)
|
|
433
|
+
except OSError:
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
def find_session(self, session_id):
|
|
437
|
+
"""A new fd of sessions/YYYY/MM/DD/<session id> in the default data directory (the newest DAY_DIRS days), or
|
|
438
|
+
None."""
|
|
439
|
+
try:
|
|
440
|
+
top = self._open(self.home + list(DATA_HOME) + [SESSIONS])
|
|
441
|
+
except OSError:
|
|
442
|
+
return None
|
|
443
|
+
try:
|
|
444
|
+
days = []
|
|
445
|
+
for year in sorted((n for n in os.listdir(top) if DAY_PART[0].match(n)), reverse=True):
|
|
446
|
+
for month in sorted((n for n in _listdir_at(top, (year,)) if DAY_PART[1].match(n)), reverse=True):
|
|
447
|
+
for day in sorted((n for n in _listdir_at(top, (year, month)) if DAY_PART[2].match(n)),
|
|
448
|
+
reverse=True):
|
|
449
|
+
days.append((year, month, day))
|
|
450
|
+
if len(days) >= DAY_DIRS:
|
|
451
|
+
break
|
|
452
|
+
if len(days) >= DAY_DIRS:
|
|
453
|
+
break
|
|
454
|
+
if len(days) >= DAY_DIRS:
|
|
455
|
+
break
|
|
456
|
+
for day in days:
|
|
457
|
+
try:
|
|
458
|
+
return _open_dir_at(top, day + (session_id,))
|
|
459
|
+
except OSError:
|
|
460
|
+
continue
|
|
461
|
+
except OSError:
|
|
462
|
+
return None
|
|
463
|
+
finally:
|
|
464
|
+
os.close(top)
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _listdir_at(fd, parts):
|
|
469
|
+
try:
|
|
470
|
+
sub = _open_dir_at(fd, parts)
|
|
471
|
+
except OSError:
|
|
472
|
+
return []
|
|
473
|
+
try:
|
|
474
|
+
return os.listdir(sub)
|
|
475
|
+
except OSError:
|
|
476
|
+
return []
|
|
477
|
+
finally:
|
|
478
|
+
os.close(sub)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def observe(muse, proc_root, pid, now):
|
|
482
|
+
"""The verdict for one Muse process, or None (no session joined, none readable, stale, ended, or saying nothing)."""
|
|
483
|
+
parts = lock_session(proc_root, pid)
|
|
484
|
+
session_fd = muse.session_dir(parts) if parts else None
|
|
485
|
+
session_id = parts[-1] if parts else None
|
|
486
|
+
if session_fd is None:
|
|
487
|
+
session_id = muse.registry().get(int(pid))
|
|
488
|
+
session_fd = muse.find_session(session_id) if session_id else None
|
|
489
|
+
if session_fd is None:
|
|
490
|
+
return None
|
|
491
|
+
try:
|
|
492
|
+
holder = _lock_pid(session_fd)
|
|
493
|
+
if holder is not None and holder != int(pid):
|
|
494
|
+
return None # the lock names another process: not this pane's session
|
|
495
|
+
tail = read_tail_lines(session_fd, LOG)
|
|
496
|
+
found = session_status(parse_records(tail[0]), tail[1]) if tail is not None else None
|
|
497
|
+
if found is not None and not found['name'] and not tail[1]:
|
|
498
|
+
found['name'] = head_name(read_head_lines(session_fd, LOG))
|
|
499
|
+
finally:
|
|
500
|
+
os.close(session_fd)
|
|
501
|
+
if found is None or found['ended'] or found['status'] is None:
|
|
502
|
+
return None
|
|
503
|
+
mtime = tail[2]
|
|
504
|
+
activity = max(stamp for stamp in (found['activity'], mtime) if stamp is not None)
|
|
505
|
+
if now - activity > STALE:
|
|
506
|
+
return None
|
|
507
|
+
return {'harness': HARNESS, 'status': found['status'], 'name': found['name'], 'session_id': session_id,
|
|
508
|
+
'pid': int(pid), 'since': found['since'] or 0, 'source': SOURCE_FILE, 'event': found['event']}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def muse_sessions(root, panes, *, home=CONTAINER_HOME, now=None):
|
|
512
|
+
"""{pane key: verdict} for the panes Muse Code runs in. `root` is the container's root as the host sees it
|
|
513
|
+
(`/proc/<init pid>/root`; its /proc is `root/proc`, the container's pid namespace), `panes` {pane key: the pane's
|
|
514
|
+
Muse pid} (statusd: the panes whose harness walk found `muse`), `home` the container home whose default data
|
|
515
|
+
directory holds the registry. One read per pane: the process's fd links to its `.session.lock`, else the registry's
|
|
516
|
+
`process_generation_hint`; then the session log's tail."""
|
|
517
|
+
now = time.time() if now is None else now
|
|
518
|
+
proc_root = os.path.join(str(root), 'proc')
|
|
519
|
+
muse = _MuseHome(root, home)
|
|
520
|
+
out = {}
|
|
521
|
+
for key, pid in (panes or {}).items():
|
|
522
|
+
try:
|
|
523
|
+
pid = int(pid)
|
|
524
|
+
except (TypeError, ValueError):
|
|
525
|
+
continue
|
|
526
|
+
if pid <= 0:
|
|
527
|
+
continue
|
|
528
|
+
verdict = observe(muse, proc_root, pid, now)
|
|
529
|
+
if verdict is not None:
|
|
530
|
+
out[str(key)] = verdict
|
|
531
|
+
return out
|
|
@@ -1384,7 +1384,8 @@ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
|
|
|
1384
1384
|
"""The one create recipe that gives a new tmux lane its identity: LANE_ID / LANE_WS for scripts and hooks, the
|
|
1385
1385
|
lanes/<ws>/<id> trace for Throttle (ANTHROPIC_CUSTOM_HEADERS for Claude Code, KIMI_CODE_CUSTOM_HEADERS for Kimi
|
|
1386
1386
|
Code, which blanks the Anthropic one, LANES_TRACE for Grok Build's `env_http_headers`, THROTTLE_TRACE for the
|
|
1387
|
-
rest
|
|
1387
|
+
rest, and (W13) MUSE_CUSTOM_HEADERS for Muse Code, measured but undocumented by Meta) and GROK_HOME (Grok Build's
|
|
1388
|
+
home, never grok-cli's ~/.grok; W12). `tmux new -A` attaches when the session
|
|
1388
1389
|
already exists, so the env lands only on a real create — never through `set-environment`, which would leak into
|
|
1389
1390
|
unrelated windows. `cmd` is a shell command string, appended as is."""
|
|
1390
1391
|
trace = lane_trace(lane_id, ws)
|
|
@@ -1400,7 +1401,7 @@ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
|
|
|
1400
1401
|
return (f'tmux -L {shlex.quote(socket)} new -ADs {shlex.quote(session)} -c {shlex.quote(folder)} '
|
|
1401
1402
|
f'-e LANE_ID={lane_id} -e LANE_WS={ws} -e ANTHROPIC_CUSTOM_HEADERS={header} '
|
|
1402
1403
|
f'-e THROTTLE_TRACE={trace} -e KIMI_CODE_CUSTOM_HEADERS={header} -e LANES_TRACE={trace} '
|
|
1403
|
-
f'-e GROK_HOME={CONTAINER_HOME}/.grok-build {cmd}')
|
|
1404
|
+
f'-e GROK_HOME={CONTAINER_HOME}/.grok-build -e MUSE_CUSTOM_HEADERS={header} {cmd}')
|
|
1404
1405
|
|
|
1405
1406
|
|
|
1406
1407
|
# What `lane open` gets when it runs a lane's recorded `create` for a lane that does not exist (plan Task 18; the
|
|
@@ -312,21 +312,24 @@ def load_economy(doc):
|
|
|
312
312
|
# Pinned above the upload strip: Throttle in one line, an availability bar per provider, the host and its top
|
|
313
313
|
# containers — from status.json's `fleet` and `throttle` objects ONLY (statusd publishes both, spec §2.1/§2.2), so the
|
|
314
314
|
# block costs no stat, read or fork of the sidebar's own on a tick. A pane of FLEET_FULL rows or more draws a rule and
|
|
315
|
-
# six rows (W12 added the Kimi row: FLEET_FULL 24 -> 25, so the body a full block leaves keeps its W11 floor
|
|
315
|
+
# six rows (W12 added the Kimi row: FLEET_FULL 24 -> 25, so the body a full block leaves keeps its W11 floor; W13 the
|
|
316
|
+
# Muse Code and Z.ai rows: eight rows, FLEET_FULL 25 -> 27), one of
|
|
316
317
|
# FLEET_SHORT..FLEET_FULL − 1 rows three (Throttle, the worse provider, the host), a shorter one none; `i` folds it to
|
|
317
318
|
# one line (`look.fleet_collapsed` in the owner's profile). The lane list scrolls in the rest.
|
|
318
319
|
FLEET_SCHEMA = 1 # the `v` of both objects this reader understands (absent = 1; another is ignored)
|
|
319
|
-
FLEET_FULL, FLEET_SHORT =
|
|
320
|
+
FLEET_FULL, FLEET_SHORT = 27, 18
|
|
320
321
|
# Own-clock floors beneath the publishers' `stale` flags (DEF-1528's rule, as ECONOMY_STALE): statusd writes `fleet`
|
|
321
322
|
# every 10 s (a 12 s cycle at its slowest) and the pulse `throttle.at` at least every 60 s, which statusd flags past
|
|
322
323
|
# 60 s; a status.json that stopped changing (statusd frozen) still turns the block stale here.
|
|
323
324
|
FLEET_STALE_S, PULSE_STALE_S = 60, 90
|
|
324
325
|
BAR_CELLS = 10
|
|
325
326
|
# The pulse's pools, in row order, and the row's word: the vendor (W12, spec 2026-10-15 §6: Kimi Code's pool is
|
|
326
|
-
# `kimi`, its vendor Moonshot AI
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
327
|
+
# `kimi`, its vendor Moonshot AI; W13: Throttle's native `muse-code` pool, vendor Meta, and `zai`, vendor Z.ai). No grok
|
|
328
|
+
# row until Throttle has an xAI pool (the 2026-10-14 xAI pool review's gates).
|
|
329
|
+
PROVIDERS = (('claude', 'anthropic'), ('codex', 'openai'), ('kimi', 'moonshot'), ('muse-code', 'meta'),
|
|
330
|
+
('zai', 'z.ai'))
|
|
331
|
+
# The pools whose overflow is money (fleet_money): Kimi, Muse Code and Z.ai have no paid overflow lane, so their figure is
|
|
332
|
+
# never shown — the pulse records it as nulls (throttle-pulse MONEY_POOLS), and this reader would not show one if it did not.
|
|
330
333
|
MONEY_POOLS = ('claude', 'codex')
|
|
331
334
|
LABEL_CELLS = 9 # the label field of the Throttle and provider rows (`anthropic`), then one blank
|
|
332
335
|
# `429 +N`: how much the pools' upstream_429_total grew over the last SURGE_S seconds of pulse samples this sidebar
|
|
@@ -97,7 +97,8 @@ PULSE_REARM_TIMEOUT = 10
|
|
|
97
97
|
DAEMON_REFRESH = 300 # site.conf is re-read this often for LANES_THROTTLE_CONTAINER (CONTAINER_REFRESH)
|
|
98
98
|
PULSE_BIN = ('home', 'coder', '.lz', 'bin')
|
|
99
99
|
PULSE_PROGRAM = 'throttle-pulse'
|
|
100
|
-
|
|
100
|
+
# throttle-pulse POOLS (W12 adds kimi, W13 muse-code and zai; their overflow is always null)
|
|
101
|
+
PULSE_POOLS = ('claude', 'codex', 'kimi', 'muse-code', 'zai')
|
|
101
102
|
PULSE_COUNTS = ('configured', 'usable', 'cooling', 'exhausted', 'benched', 'auth_failed')
|
|
102
103
|
PULSE_OVERFLOW = ('spend_today_usd', 'budget_usd', 'fraction', 'alert_level_pct')
|
|
103
104
|
PULSE_PHASE = re.compile(r'[a-z][a-z0-9_-]{0,31}\Z', re.ASCII)
|
|
@@ -348,14 +349,15 @@ def agent_signals(containers, sessions_by_server, now=None):
|
|
|
348
349
|
"""The per-container inputs `agent_state.merge` needs, read ONCE per container per cycle through the
|
|
349
350
|
container's root as the host sees it (`/proc/<init pid>/root`), so liveness is judged in the container's pid
|
|
350
351
|
namespace and nothing is spawned. -> {container: {'claude': claude_by_pane, 'kimi': kimi_by_pane,
|
|
351
|
-
'grok': grok_by_pane}}.
|
|
352
|
+
'grok': grok_by_pane, 'muse': muse_by_pane}}.
|
|
352
353
|
|
|
353
354
|
Sets `harness` (and `harness_pid` when one was found) on every session row whose harness could be observed, in
|
|
354
355
|
place (W12, spec §2: `agent_state.HarnessCache` over `harness_walk` — the pane's process tree in the container's
|
|
355
356
|
/proc); the Kimi Code observer (`agent_state.kimi_sessions`) reads only for the panes found running `kimi`, and the
|
|
356
357
|
Grok Build observer (`agent_state.grok_sessions` -> `harness_grok.grok_sessions`) gets the ROWS of the panes found
|
|
357
358
|
running `grok`, ending in a bare shell, or running a grok whose exe could not be read (GROK_OBSERVED), with the
|
|
358
|
-
container's GROK_TITLES memory.
|
|
359
|
+
container's GROK_TITLES memory. W13: the Muse Code observer (`agent_state.muse_sessions` ->
|
|
360
|
+
`harness_muse.muse_sessions`) reads only for the panes found running `muse`, by their Muse pid.
|
|
359
361
|
|
|
360
362
|
Also sets `pane_has_child` on the session rows that need it, in place: only a pane whose title Codex wrote
|
|
361
363
|
(`parse_codex_title`, gated by the observed harness) over a bare-shell foreground can be a harness that ended into
|
|
@@ -378,10 +380,10 @@ def agent_signals(containers, sessions_by_server, now=None):
|
|
|
378
380
|
except Exception as error:
|
|
379
381
|
log(f'agents {container}', error)
|
|
380
382
|
claude = {}
|
|
381
|
-
signals[container] = mine = {'claude': claude, 'kimi': {}, 'grok': {}}
|
|
383
|
+
signals[container] = mine = {'claude': claude, 'kimi': {}, 'grok': {}, 'muse': {}}
|
|
382
384
|
panes = [pane for (owner, _socket), sessions in sessions_by_server.items() if owner == container
|
|
383
385
|
for pane in sessions.values() if str(pane.get('pane_pid') or '').isdigit()]
|
|
384
|
-
observers = {'kimi': {}, 'grok': {}}
|
|
386
|
+
observers = {'kimi': {}, 'grok': {}, 'muse': {}}
|
|
385
387
|
for pane in panes:
|
|
386
388
|
try:
|
|
387
389
|
harness, harness_pid = HARNESS_CACHE.harness(root / 'proc', int(pane['pane_pid']))
|
|
@@ -396,6 +398,8 @@ def agent_signals(containers, sessions_by_server, now=None):
|
|
|
396
398
|
pane['harness_pid'] = harness_pid
|
|
397
399
|
if harness == 'kimi' and harness_pid:
|
|
398
400
|
observers['kimi'][AS.pane_key(pane)] = harness_pid
|
|
401
|
+
elif harness == 'muse' and harness_pid:
|
|
402
|
+
observers['muse'][AS.pane_key(pane)] = harness_pid
|
|
399
403
|
elif harness in GROK_OBSERVED or (harness == 'unknown' and harness_pid):
|
|
400
404
|
observers['grok'][AS.pane_key(pane)] = pane
|
|
401
405
|
if observers['kimi']:
|
|
@@ -403,6 +407,11 @@ def agent_signals(containers, sessions_by_server, now=None):
|
|
|
403
407
|
mine['kimi'] = AS.kimi_sessions(root, observers['kimi'], now, home)
|
|
404
408
|
except Exception as error:
|
|
405
409
|
log(f'kimi {container}', error)
|
|
410
|
+
if observers['muse']:
|
|
411
|
+
try:
|
|
412
|
+
mine['muse'] = AS.muse_sessions(root, observers['muse'], home=L.CONTAINER_HOME, now=now)
|
|
413
|
+
except Exception as error:
|
|
414
|
+
log(f'muse {container}', error)
|
|
406
415
|
if observers['grok']:
|
|
407
416
|
titles = GROK_TITLES.setdefault(str(root), {})
|
|
408
417
|
titled.add(str(root))
|
|
@@ -435,7 +444,8 @@ def agent_signals(containers, sessions_by_server, now=None):
|
|
|
435
444
|
|
|
436
445
|
def merged(pane, signals, sidecar):
|
|
437
446
|
"""`agent_state.merge` of one pane over its container's `agent_signals` and its lane's hook sidecar."""
|
|
438
|
-
return AS.merge(pane, signals.get('claude', {}), sidecar, signals.get('kimi', {}), signals.get('grok', {})
|
|
447
|
+
return AS.merge(pane, signals.get('claude', {}), sidecar, signals.get('kimi', {}), signals.get('grok', {}),
|
|
448
|
+
signals.get('muse', {}))
|
|
439
449
|
|
|
440
450
|
|
|
441
451
|
SIDECAR_MAX_AGE = 3600 # a hook sidecar whose last event is older than this loses to the live signals
|