@vibelord/lanes 1.3.0 → 1.5.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.
@@ -0,0 +1,742 @@
1
+ """Grok Build and grok-cli observers for agent{} (Lanes W12 Track B; spec
2
+ docs/superpowers/specs/2026-10-15-lanes-harness-observers-design.md §4 and §5). Pure reads through the container root
3
+ statusd already holds (`/proc/<container init pid>/root`): no subprocess, no write, nothing done at import.
4
+
5
+ Grok Build (xai-org/grok-build 1.0.52, the official CLI; evidence memo
6
+ docs/superpowers/specs/evidence/2026-10-15-grok-build-measurement.md) keeps its state under `$GROK_HOME`. Lanes runs it
7
+ with `GROK_HOME=~/.grok-build` (GROK_HOME_DEFAULT): its installer otherwise replaces grok-cli's `~/.grok/bin/grok` with
8
+ a symlink. Both CLIs run as comm `grok`; the binary tells them apart (`harness_of_exe`): Grok Build's is
9
+ `$GROK_HOME/downloads/grok-linux-<arch>`, anything else named grok is grok-cli.
10
+
11
+ `grok_sessions(root, panes, grok_home)` -> {pane key: verdict}, the dict `agent_state.merge` takes from a session file:
12
+ {harness: 'grok', status, name, session_id, pid, since, source}, `source` 'grok-session-file' or 'grok-title'. Per pane:
13
+
14
+ 1. The pane's process tree (pane pid and descendants to depth WALK_DEPTH, `/proc/<pid>/task/*/children` and `comm`)
15
+ names the Grok Build pid. No grok process in a tree read in full, under a Grok-shaped title: `shell` (the harness
16
+ ended, tmux kept the title it reset to `grok`). A tree that could not be read is unknown and never demotes.
17
+ 2. `$GROK_HOME/active_sessions.json` (`[{session_id, pid, cwd, opened_at}]`, written by the TUI with its own pid [S])
18
+ joins that pid to a session. Entries whose pid is gone, is no longer `grok`, or started after `opened_at` (a
19
+ recycled pid) are ignored; two live entries for one pid (tabs) are ambiguous and fall back to the title.
20
+ 3. `sessions/<URL-encoded cwd>/<session id>/events.jsonl` (schema 1.0, append-only [S]): its last TAIL_BYTES, whole
21
+ lines only, give the state (`events_status`): `turn_started`, `phase_changed` waiting_for_model | streaming_text |
22
+ streaming_reasoning | tool_execution, `permission_resolved` and the in-turn events -> working;
23
+ `phase_changed{permission_prompt}` / `permission_requested` -> needs_approval; `turn_ended.outcome` completed ->
24
+ needs_input (done when the pane's bell flag is set), error -> errored, cancelled | interrupted -> needs_input; no
25
+ turn yet -> idle. `summary.json` `generated_title` -> name.
26
+ 4. Otherwise the pane title (`parse_grok_title`, OSC 0, on by default [S]): `⚠ Action Required - …` ->
27
+ needs_approval, a braille spinner -> working, `grok` / `<name> - grok` -> idle (done with the bell).
28
+
29
+ Every file under `$GROK_HOME` is opened without following a link, every directory component O_NOFOLLOW (a container
30
+ owns them): a symlinked GROK_HOME, session directory or file is refused, a FIFO is never opened blocking, and nothing
31
+ larger than its bound is read (JSON_MAX; TAIL_BYTES of events.jsonl). A cwd whose encoded name exceeds 255 bytes is
32
+ filed by Grok as `<slug>-<blake3>` (no stdlib blake3): its directory is found by the `.cwd` file Grok writes into it.
33
+
34
+ Leader mode (`[cli] use_leader = true`, spec §4: out of scope): a socket `leader.sock` / `leader-*.sock` in GROK_HOME,
35
+ or a live `active_sessions` entry for the pane grok's cwd whose pid is no pane's grok while the pane's own pid has
36
+ none, publishes status UNKNOWN with a `note`. UNKNOWN is outside agent_state.STATUSES, so `agent_state.settle`
37
+ publishes no agent{} for it; the observer never guesses a session it cannot join.
38
+
39
+ The title blinks: with a permission pending and the pane unfocused, Grok drops the `⚠ Action Required` item for 0.5 s
40
+ of every 1 s [S notifications/title.rs ACTION_REQUIRED_BLINK_DIVISOR]. After a sample that showed it, a busy title
41
+ (spinner) without it is still needs_approval (`parse_grok_title(title, previous_title)`; `grok_sessions(...,
42
+ titles=dict)` keeps the previous title per pane while it is prefixed); an idle title is not, since a permission is only
43
+ pending inside a turn. Two samples a statusd cycle apart can both land in the hidden half: the session file's
44
+ `permission_requested` is the primary approval signal and the title only its fallback (DEF-1982).
45
+
46
+ Labels: [S] read from source (grok-build main 2bdd1d6a: xai-grok-active-sessions, xai-grok-session-events,
47
+ xai-grok-config `encode_cwd_dirname`, notifications/title.rs); [D] the shipped user guide; [M] measured on this host.
48
+ NOT measured: a logged-in session's active_sessions.json, events.jsonl, summary.json and busy/approval titles (no
49
+ account existed); the fixtures are built from the [S] vocabulary and marked needs_operator_capture (DEF-1981).
50
+
51
+ grok-cli (superagent-ai/grok-cli 1.1.7) emits nothing a host can read [M]: no title, no bell, and its `grok.db` holds
52
+ no pid. GROK_CLI records what is known; its state reaches Lanes only through the hook sidecar `lane-signal` writes
53
+ (hooks: W12 Track C), and it has no approval signal at all (DEF-1980). A pane whose only grok is grok-cli gets no
54
+ verdict here.
55
+ """
56
+ import calendar
57
+ import json
58
+ import os
59
+ import posixpath
60
+ import re
61
+ import stat
62
+ import types
63
+ import urllib.parse
64
+
65
+ HARNESS = 'grok'
66
+ CLI_HARNESS = 'grok-cli'
67
+ GROK_HOME_DEFAULT = '.grok-build' # relative to the container home; an absolute GROK_HOME is taken as given
68
+ CONTAINER_HOME = '/home/coder'
69
+ COMM = 'grok' # /proc/<pid>/comm of both CLIs [M]
70
+ EXE_DIR = 'downloads' # Grok Build's binary: $GROK_HOME/downloads/grok-linux-<arch> [M]
71
+ EXE_PREFIX = 'grok-linux-'
72
+ FLAVOUR_UNREADABLE = '' # comm `grok`, /proc/<pid>/exe unreadable: Grok Build only if a session names it
73
+
74
+ ACTIVE_SESSIONS = 'active_sessions.json'
75
+ SESSIONS = 'sessions'
76
+ EVENTS = 'events.jsonl'
77
+ SUMMARY = 'summary.json'
78
+ CWD_FILE = '.cwd'
79
+ LEADER_SOCKET = re.compile(r'leader(-[A-Za-z0-9._-]+)?\.sock\Z')
80
+ SESSION_ID = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z')
81
+
82
+ TAIL_BYTES = 64 * 1024 # the events.jsonl tail read per pane per cycle
83
+ JSON_MAX = 256 * 1024 # active_sessions.json, summary.json
84
+ CWD_MAX = 4096
85
+ DIRNAME_MAX = 255 # Grok's MAX_DIRNAME_BYTES: a longer encoded cwd is a slug-hash name [S]
86
+ SCAN_MAX = 256 # slug-hash directories looked at for one long cwd
87
+ WALK_DEPTH = 4
88
+ WALK_MAX = 64 # processes read per pane tree
89
+ START_SLACK = 2 # seconds: a pid that started later than opened_at + this is a recycled pid
90
+ NAME_MAX = 120
91
+
92
+ ACTION = '⚠ Action Required'
93
+ SPINNER = frozenset('⠋⠙⠹⠸⠼⠴⠦⠧') # TITLE_SPINNER [S]
94
+ SEPARATOR = ' - '
95
+ TITLE_END = 'grok'
96
+ TITLE_BLINK_S = 0.5
97
+
98
+ SOURCE_FILE = 'grok-session-file'
99
+ SOURCE_TITLE = 'grok-title'
100
+ UNKNOWN = 'unknown'
101
+ VERDICT_KEYS = ('harness', 'status', 'name', 'session_id', 'pid', 'since', 'source')
102
+
103
+ APPROVAL_PHASE = 'permission_prompt'
104
+ WORKING_PHASES = frozenset(('waiting_for_model', 'streaming_text', 'streaming_reasoning', 'tool_execution'))
105
+ IN_TURN = frozenset(('first_token', 'loop_started', 'tool_started', 'tool_completed', 'interjected'))
106
+ OUTCOME = {'completed': 'needs_input', 'error': 'errored', 'cancelled': 'needs_input', 'interrupted': 'needs_input'}
107
+
108
+ # grok-cli: what is known, nothing to observe. The hook events and the words they write are spec §5's; the hook
109
+ # definitions themselves are Track C's (`lanes hooks install --agent grok-cli`).
110
+ GROK_CLI = types.MappingProxyType({
111
+ 'harness': CLI_HARNESS,
112
+ 'package': 'superagent-ai/grok-cli',
113
+ 'measured_version': '1.1.7',
114
+ 'home': '.grok',
115
+ 'settings': 'user-settings.json',
116
+ 'install_marker': 'install.json',
117
+ 'observed': (),
118
+ 'hook_status': types.MappingProxyType({
119
+ 'UserPromptSubmit': 'working', 'PreToolUse': 'working', 'PostToolUse': 'working',
120
+ 'Stop': 'needs_input', 'StopFailure': 'errored', 'SessionEnd': 'shell'}),
121
+ 'approval_signal': None,
122
+ 'trace_header': None,
123
+ })
124
+
125
+ _DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_CLOEXEC', 0)
126
+ _FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0)
127
+ _RFC3339 = re.compile(r'(\d{4})-(\d\d)-(\d\d)[Tt ](\d\d):(\d\d):(\d\d)(?:\.(\d+))?([Zz]|[+-]\d\d:?\d\d)\Z')
128
+
129
+
130
+ # ------------------------------------------------------------------------------------------------ pure parsers
131
+
132
+ def pane_key(row):
133
+ """The key `agent_state.merge` looks a pane up by: `<session>:<window id>.<pane id>`."""
134
+ return f"{row.get('session_name')}:{row.get('window_id')}.{row.get('pane_id')}"
135
+
136
+
137
+ def harness_of_exe(exe):
138
+ """'grok' for Grok Build's binary (`…/downloads/grok-linux-<arch>`, also once replaced by an update, ` (deleted)`),
139
+ 'grok-cli' for any other binary of a process named grok, None for no path."""
140
+ if not isinstance(exe, str) or not exe:
141
+ return None
142
+ path = exe[:-len(' (deleted)')] if exe.endswith(' (deleted)') else exe
143
+ parent, _slash, name = path.rpartition('/')
144
+ if name.startswith(EXE_PREFIX) and parent.rpartition('/')[2] == EXE_DIR:
145
+ return HARNESS
146
+ return CLI_HARNESS
147
+
148
+
149
+ def encode_cwd_dirname(cwd):
150
+ """The directory Grok Build files an absolute cwd's sessions under (xai-grok-config `encode_cwd_dirname` [S]): the
151
+ cwd URL-encoded, every byte outside `A-Z a-z 0-9 - . _ ~` as upper-case %XX — the set `quote(safe='')` keeps.
152
+ None when that exceeds 255 bytes (Grok then uses `<slug>-<blake3 hex16>`) or the cwd is not absolute."""
153
+ if not isinstance(cwd, str) or not cwd.startswith('/'):
154
+ return None
155
+ try:
156
+ encoded = urllib.parse.quote(cwd, safe='')
157
+ except UnicodeError:
158
+ return None
159
+ return encoded if len(encoded) <= DIRNAME_MAX else None
160
+
161
+
162
+ def parse_rfc3339(text):
163
+ """Epoch seconds (float) of an RFC 3339 stamp as chrono writes them (`2026-10-15T09:00:00.123Z`, nanoseconds in
164
+ active_sessions' `opened_at`), or None."""
165
+ match = _RFC3339.match(text) if isinstance(text, str) else None
166
+ if not match:
167
+ return None
168
+ year, month, day, hour, minute, second = (int(group) for group in match.groups()[:6])
169
+ if not (1 <= month <= 12 and 1 <= day <= 31 and hour < 24 and minute < 60 and second < 61):
170
+ return None
171
+ epoch = calendar.timegm((year, month, day, hour, minute, second, 0, 0, 0))
172
+ if match.group(7):
173
+ epoch += float('0.' + match.group(7))
174
+ zone = match.group(8)
175
+ if zone not in ('Z', 'z'):
176
+ digits = zone[1:].replace(':', '')
177
+ offset = int(digits[:2]) * 3600 + int(digits[2:]) * 60
178
+ epoch += -offset if zone[0] == '+' else offset
179
+ return epoch
180
+
181
+
182
+ def _clean_title(title):
183
+ return title.strip() if isinstance(title, str) else ''
184
+
185
+
186
+ def _title_parts(title):
187
+ """The ` - `-joined items of a Grok Build title, or None when it is not one (the `grok` item always ends it)."""
188
+ if title == TITLE_END:
189
+ return [TITLE_END]
190
+ if title.endswith(SEPARATOR + TITLE_END):
191
+ return title.split(SEPARATOR)
192
+ return None
193
+
194
+
195
+ def _prefixed(title):
196
+ parts = _title_parts(_clean_title(title))
197
+ return bool(parts) and parts[0] == ACTION
198
+
199
+
200
+ def parse_grok_title(title, previous_title=None):
201
+ """A Grok Build pane title -> {'harness': 'grok', 'status', 'name'}, or None for any other title. The default title
202
+ items are action-required, spinner, activity, session-name, grok, joined by ` - ` [S]: `⚠ Action Required` first ->
203
+ needs_approval; a braille spinner (followed by its activity) -> working; else idle (`grok`, `<name> - grok`). The
204
+ name is the session-name item. `previous_title` is the pane's previous sample: the prefix blinks while the pane is
205
+ unfocused, so after a prefixed sample a BUSY title without it is still needs_approval. A permission is only pending
206
+ inside a turn, and the spinner shows for the whole turn [S], so an idle title after the prefix is idle: the turn
207
+ ended."""
208
+ parts = _title_parts(_clean_title(title))
209
+ if parts is None:
210
+ return None
211
+ approval = parts[0] == ACTION
212
+ if approval:
213
+ parts = parts[1:]
214
+ busy = len(parts) > 1 and len(parts[0]) == 1 and parts[0] in SPINNER
215
+ name = SEPARATOR.join(parts[2:-1] if busy else parts[:-1])
216
+ if approval or (busy and _prefixed(previous_title)):
217
+ status = 'needs_approval'
218
+ else:
219
+ status = 'working' if busy else 'idle'
220
+ return {'harness': HARNESS, 'status': status, 'name': name}
221
+
222
+
223
+ def parse_events(lines):
224
+ """The event dicts of events.jsonl lines (bytes); a line that is not one JSON object with a `type` is skipped."""
225
+ events = []
226
+ for line in lines:
227
+ try:
228
+ event = json.loads(line.decode('utf-8') if isinstance(line, bytes) else line)
229
+ except (UnicodeDecodeError, ValueError):
230
+ continue
231
+ if isinstance(event, dict) and isinstance(event.get('type'), str):
232
+ events.append(event)
233
+ return events
234
+
235
+
236
+ def events_status(events, whole=True):
237
+ """{'status', 'since', 'outcome'} from events.jsonl's events in file order, or None when they say nothing.
238
+
239
+ The last state event wins: `turn_started` -> working; `phase_changed` permission_prompt -> needs_approval, another
240
+ phase -> working; `permission_requested` -> needs_approval, `permission_resolved` -> working; `turn_ended` by
241
+ `outcome` (OUTCOME; an unknown outcome still ends the turn: needs_input). The in-turn events (first_token,
242
+ loop_started, tool_started, tool_completed, interjected) mean working, except while an approval is pending (a
243
+ parallel tool's completion does not answer the prompt) or between turns. Every other event (MCP, goal, todo …) says
244
+ nothing. `since` is the epoch second of the event that began the status (kept while the status repeats), None
245
+ when its `ts` is unreadable; `outcome` the ending turn's outcome when the status came from `turn_ended`.
246
+
247
+ No state event at all: idle when `whole` (the file was read from its first byte — a session with no turn yet),
248
+ else None (the tail window holds none; the caller falls back to the title)."""
249
+ status = since = outcome = None
250
+ in_turn = None # None: no turn boundary seen in this window
251
+ for event in events:
252
+ kind = event.get('type')
253
+ new, ended = None, None
254
+ if kind == 'turn_started':
255
+ in_turn, new = True, 'working'
256
+ elif kind == 'turn_ended':
257
+ in_turn = False
258
+ ended = event.get('outcome') if isinstance(event.get('outcome'), str) else ''
259
+ new = OUTCOME.get(ended, 'needs_input')
260
+ elif in_turn is False:
261
+ continue
262
+ elif kind == 'phase_changed':
263
+ new = 'needs_approval' if event.get('phase') == APPROVAL_PHASE else 'working'
264
+ elif kind == 'permission_requested':
265
+ new = 'needs_approval'
266
+ elif kind == 'permission_resolved':
267
+ new = 'working'
268
+ elif kind in IN_TURN:
269
+ new = None if status == 'needs_approval' else 'working'
270
+ if new is None:
271
+ continue
272
+ if new != status:
273
+ stamp = parse_rfc3339(event.get('ts'))
274
+ since = int(stamp) if stamp is not None else None
275
+ status, outcome = new, ended
276
+ if status is None:
277
+ return {'status': 'idle', 'since': None, 'outcome': None} if whole else None
278
+ return {'status': status, 'since': since, 'outcome': outcome}
279
+
280
+
281
+ def parse_active_sessions(data):
282
+ """The well-formed entries of active_sessions.json (bytes): [{session_id, pid, cwd, opened_at (epoch or None)}].
283
+ Grok itself reads a corrupt file as empty [S]; so does this."""
284
+ try:
285
+ document = json.loads(data.decode('utf-8')) if data else []
286
+ except (UnicodeDecodeError, ValueError):
287
+ return []
288
+ entries = []
289
+ for item in document if isinstance(document, list) else ():
290
+ if not isinstance(item, dict):
291
+ continue
292
+ sid, pid, cwd = item.get('session_id'), item.get('pid'), item.get('cwd')
293
+ if (not isinstance(sid, str) or not SESSION_ID.match(sid) or isinstance(pid, bool) or not isinstance(pid, int)
294
+ or pid <= 0 or not isinstance(cwd, str)):
295
+ continue
296
+ entries.append({'session_id': sid, 'pid': pid, 'cwd': cwd, 'opened_at': parse_rfc3339(item.get('opened_at'))})
297
+ return entries
298
+
299
+
300
+ def _printable(text):
301
+ if not isinstance(text, str):
302
+ return ''
303
+ return ''.join(ch for ch in text if ch.isprintable()).strip()[:NAME_MAX]
304
+
305
+
306
+ def summary_title(data, session_id=None):
307
+ """summary.json's `generated_title` (bytes in), printable and at most NAME_MAX characters; '' when absent,
308
+ malformed, or the file's `info.id` names another session."""
309
+ try:
310
+ document = json.loads(data.decode('utf-8')) if data else None
311
+ except (UnicodeDecodeError, ValueError):
312
+ return ''
313
+ if not isinstance(document, dict):
314
+ return ''
315
+ info = document.get('info')
316
+ if session_id and isinstance(info, dict) and info.get('id') not in (None, session_id):
317
+ return ''
318
+ return _printable(document.get('generated_title'))
319
+
320
+
321
+ # ------------------------------------------------------------------------------------------------ bounded reads
322
+
323
+ def _leaf(name):
324
+ return isinstance(name, str) and bool(name) and '/' not in name and '\0' not in name and name not in ('.', '..')
325
+
326
+
327
+ def _open_dir_at(dir_fd, parts):
328
+ """A new fd of dir_fd/<parts…>, every component a REAL directory opened O_NOFOLLOW: a symlink raises OSError, never
329
+ followed. `dir_fd` stays open; the caller closes the result."""
330
+ current = os.dup(dir_fd)
331
+ try:
332
+ for part in parts:
333
+ if not _leaf(part):
334
+ raise OSError(f'not one path component: {part!r}')
335
+ child = os.open(part, _DIR_FLAGS | os.O_NOFOLLOW, dir_fd=current)
336
+ os.close(current)
337
+ current = child
338
+ except BaseException:
339
+ os.close(current)
340
+ raise
341
+ return current
342
+
343
+
344
+ def _read_at(fd, offset, count):
345
+ chunks, size = [], 0
346
+ while size < count:
347
+ chunk = os.pread(fd, count - size, offset + size)
348
+ if not chunk:
349
+ break
350
+ chunks.append(chunk)
351
+ size += len(chunk)
352
+ return b''.join(chunks)
353
+
354
+
355
+ def read_regular_at(dir_fd, name, limit):
356
+ """The bytes of `name` in `dir_fd` when it is a regular file of at most `limit` bytes, else None (absent, a link, a
357
+ FIFO or device, bigger). Never follows a link, never blocks (O_NONBLOCK)."""
358
+ if dir_fd is None or not _leaf(name):
359
+ return None
360
+ try:
361
+ fd = os.open(name, _FILE_FLAGS, dir_fd=dir_fd)
362
+ except OSError:
363
+ return None
364
+ try:
365
+ info = os.fstat(fd)
366
+ if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
367
+ return None
368
+ data = _read_at(fd, 0, limit + 1)
369
+ return data if len(data) <= limit else None
370
+ except OSError:
371
+ return None
372
+ finally:
373
+ os.close(fd)
374
+
375
+
376
+ def read_tail_lines(dir_fd, name, limit=TAIL_BYTES):
377
+ """(lines, whole) — the complete lines in the last `limit` bytes of `name` (newline stripped, empty lines dropped)
378
+ and whether the window reached the file's first byte. A first line cut by the window and a last line still being
379
+ written (no newline yet) are dropped, never parsed. An absent file is an empty one ([], True); a link, FIFO, device
380
+ or unreadable file is None. Never follows a link, never blocks."""
381
+ if dir_fd is None or not _leaf(name):
382
+ return None
383
+ try:
384
+ fd = os.open(name, _FILE_FLAGS, dir_fd=dir_fd)
385
+ except FileNotFoundError:
386
+ return [], True
387
+ except OSError:
388
+ return None
389
+ try:
390
+ info = os.fstat(fd)
391
+ if not stat.S_ISREG(info.st_mode):
392
+ return None
393
+ size = info.st_size
394
+ start = max(0, size - limit)
395
+ if start:
396
+ data = _read_at(fd, start - 1, size - start + 1)
397
+ if data[:1] == b'\n':
398
+ data = data[1:] # the window starts exactly on a line
399
+ else:
400
+ cut = data.find(b'\n')
401
+ data = b'' if cut < 0 else data[cut + 1:]
402
+ else:
403
+ data = _read_at(fd, 0, size)
404
+ except OSError:
405
+ return None
406
+ finally:
407
+ os.close(fd)
408
+ if not data.endswith(b'\n'):
409
+ cut = data.rfind(b'\n')
410
+ data = b'' if cut < 0 else data[:cut + 1]
411
+ return [line for line in data.split(b'\n') if line.strip()], start == 0
412
+
413
+
414
+ def _proc_text(proc_root, pid, name, limit=4096):
415
+ try:
416
+ with open(os.path.join(proc_root, str(pid), name), 'rb') as handle:
417
+ return handle.read(limit).decode('utf-8', 'replace')
418
+ except OSError:
419
+ return None
420
+
421
+
422
+ def _proc_link(proc_root, pid, name):
423
+ try:
424
+ return os.readlink(os.path.join(proc_root, str(pid), name))
425
+ except OSError:
426
+ return None
427
+
428
+
429
+ def _children(proc_root, pid):
430
+ """The child pids of `pid` from every /proc/<pid>/task/<tid>/children, or None when any read fails."""
431
+ task = os.path.join(proc_root, str(pid), 'task')
432
+ try:
433
+ tids = os.listdir(task)
434
+ except OSError:
435
+ return None
436
+ if not tids:
437
+ return None
438
+ kids = []
439
+ for tid in tids:
440
+ try:
441
+ with open(os.path.join(task, tid, 'children'), 'rb') as handle:
442
+ kids += [int(word) for word in handle.read().split() if word.isdigit()]
443
+ except (OSError, ValueError):
444
+ return None
445
+ return kids
446
+
447
+
448
+ def grok_flavour(proc_root, pid):
449
+ """'grok' | 'grok-cli' for a process named grok, FLAVOUR_UNREADABLE when its exe link cannot be read, None when the
450
+ process is not named grok (or is gone)."""
451
+ comm = _proc_text(proc_root, pid, 'comm')
452
+ if comm is None or comm.strip() != COMM:
453
+ return None
454
+ exe = _proc_link(proc_root, pid, 'exe')
455
+ return FLAVOUR_UNREADABLE if exe is None else harness_of_exe(exe)
456
+
457
+
458
+ def pane_grok_pids(proc_root, pane_pid, depth=WALK_DEPTH, limit=WALK_MAX):
459
+ """([(pid, flavour)], complete): the processes named grok in the pane's tree — the pane pid itself and its
460
+ descendants to `depth` levels, breadth first, at most `limit` processes, never below a grok. `complete` is False
461
+ when any read failed, the bound was hit or a process at the depth limit has children: "found none" then means
462
+ "could not tell", which never demotes a lane to `shell`."""
463
+ found, complete, seen = [], True, set()
464
+ queue = [(int(pane_pid), 0)]
465
+ while queue:
466
+ pid, level = queue.pop(0)
467
+ if pid in seen:
468
+ continue
469
+ if len(seen) >= limit:
470
+ complete = False
471
+ break
472
+ seen.add(pid)
473
+ comm = _proc_text(proc_root, pid, 'comm')
474
+ if comm is None:
475
+ complete = False
476
+ continue
477
+ if comm.strip() == COMM:
478
+ found.append((pid, grok_flavour(proc_root, pid) or FLAVOUR_UNREADABLE))
479
+ continue
480
+ kids = _children(proc_root, pid)
481
+ if kids is None:
482
+ complete = False
483
+ elif level >= depth:
484
+ complete = complete and not kids
485
+ else:
486
+ queue += [(kid, level + 1) for kid in kids]
487
+ return found, complete
488
+
489
+
490
+ def _boot_time(proc_root):
491
+ try:
492
+ with open(os.path.join(proc_root, 'stat'), 'rb') as handle:
493
+ text = handle.read(1 << 16).decode('ascii', 'replace')
494
+ except OSError:
495
+ return None
496
+ match = re.search(r'^btime (\d+)$', text, re.M)
497
+ return int(match.group(1)) if match else None
498
+
499
+
500
+ def _start_epoch(proc_root, pid, boot):
501
+ """The process's start in epoch seconds: /proc/<pid>/stat field 22 (clock ticks after boot) + btime, or None."""
502
+ text = _proc_text(proc_root, pid, 'stat')
503
+ try:
504
+ ticks = int(text.rsplit(')', 1)[1].split()[19])
505
+ hertz = os.sysconf('SC_CLK_TCK')
506
+ except (AttributeError, IndexError, ValueError, OSError):
507
+ return None
508
+ return boot + ticks / hertz
509
+
510
+
511
+ # ------------------------------------------------------------------------------------------------ the observer
512
+
513
+ class _GrokHome:
514
+ """One container's $GROK_HOME for one call, opened on first use: its directory fd, the live active_sessions entries
515
+ and the leader socket, each read at most once."""
516
+
517
+ def __init__(self, root, home, grok_home, proc_root):
518
+ self.root, self.proc_root = str(root), proc_root
519
+ path = grok_home if str(grok_home).startswith('/') else posixpath.join(str(home), str(grok_home))
520
+ parts = [part for part in path.split('/') if part]
521
+ self.parts = parts if parts and all(_leaf(part) for part in parts) else None
522
+ self.fd, self.opened, self._entries, self._leader = None, False, None, None
523
+
524
+ def dir(self):
525
+ if not self.opened:
526
+ self.opened = True
527
+ if self.parts is not None:
528
+ try:
529
+ base = os.open(self.root, _DIR_FLAGS)
530
+ try:
531
+ self.fd = _open_dir_at(base, self.parts)
532
+ finally:
533
+ os.close(base)
534
+ except OSError:
535
+ self.fd = None
536
+ return self.fd
537
+
538
+ def close(self):
539
+ if self.fd is not None:
540
+ os.close(self.fd)
541
+ self.fd = None
542
+
543
+ def leader(self):
544
+ """The name of a leader socket in GROK_HOME (a real socket, not a link to one), or ''."""
545
+ if self._leader is None:
546
+ self._leader = ''
547
+ fd = self.dir()
548
+ try:
549
+ names = sorted(os.listdir(fd)) if fd is not None else []
550
+ except OSError:
551
+ names = []
552
+ for name in names:
553
+ if LEADER_SOCKET.match(name):
554
+ try:
555
+ if stat.S_ISSOCK(os.stat(name, dir_fd=fd, follow_symlinks=False).st_mode):
556
+ self._leader = name
557
+ break
558
+ except OSError:
559
+ continue
560
+ return self._leader
561
+
562
+ def entries(self):
563
+ """The active_sessions entries whose pid still runs grok and started no later than the entry was opened."""
564
+ if self._entries is None:
565
+ boot = _boot_time(self.proc_root)
566
+ live = []
567
+ for entry in parse_active_sessions(read_regular_at(self.dir(), ACTIVE_SESSIONS, JSON_MAX)):
568
+ comm = _proc_text(self.proc_root, entry['pid'], 'comm')
569
+ if comm is None or comm.strip() != COMM:
570
+ continue # gone, or the pid is someone else's now
571
+ if boot is not None and entry['opened_at'] is not None:
572
+ start = _start_epoch(self.proc_root, entry['pid'], boot)
573
+ if start is not None and start > entry['opened_at'] + START_SLACK:
574
+ continue # a grok started after the entry: a recycled pid
575
+ live.append(entry)
576
+ self._entries = live
577
+ return self._entries
578
+
579
+ def session_dir(self, cwd, session_id):
580
+ """A new fd of sessions/<encoded cwd>/<session id>, or None. A long cwd's slug-hash directory is the one whose
581
+ `.cwd` file holds the cwd (at most SCAN_MAX directories looked at)."""
582
+ fd = self.dir()
583
+ if fd is None or not SESSION_ID.match(session_id) or not isinstance(cwd, str) or not cwd.startswith('/'):
584
+ return None
585
+ encoded = encode_cwd_dirname(cwd)
586
+ try:
587
+ if encoded is not None:
588
+ return _open_dir_at(fd, (SESSIONS, encoded, session_id))
589
+ sessions = _open_dir_at(fd, (SESSIONS,))
590
+ except OSError:
591
+ return None
592
+ try:
593
+ names = [name for name in sorted(os.listdir(sessions)) if not name.startswith('%') and _leaf(name)]
594
+ for name in names[:SCAN_MAX]:
595
+ try:
596
+ group = _open_dir_at(sessions, (name,))
597
+ except OSError:
598
+ continue
599
+ try:
600
+ recorded = read_regular_at(group, CWD_FILE, CWD_MAX)
601
+ if recorded is not None and recorded.decode('utf-8', 'replace').strip() == cwd:
602
+ try:
603
+ return _open_dir_at(group, (session_id,))
604
+ except OSError:
605
+ return None
606
+ finally:
607
+ os.close(group)
608
+ except OSError:
609
+ return None
610
+ finally:
611
+ os.close(sessions)
612
+ return None
613
+
614
+ def session(self, entry):
615
+ """(events_status dict, summary title) of one active session, or None when its files cannot be read or say
616
+ nothing."""
617
+ sfd = self.session_dir(entry['cwd'], entry['session_id'])
618
+ if sfd is None:
619
+ return None
620
+ try:
621
+ tail = read_tail_lines(sfd, EVENTS)
622
+ summary = read_regular_at(sfd, SUMMARY, JSON_MAX)
623
+ finally:
624
+ os.close(sfd)
625
+ if tail is None:
626
+ return None
627
+ state = events_status(parse_events(tail[0]), tail[1])
628
+ return None if state is None else (state, summary_title(summary, entry['session_id']))
629
+
630
+
631
+ def _int(value):
632
+ try:
633
+ return int(value or 0)
634
+ except (TypeError, ValueError):
635
+ return 0
636
+
637
+
638
+ def _command(row):
639
+ return str(row.get('pane_current_command') or '').strip().lstrip('-').rsplit('/', 1)[-1]
640
+
641
+
642
+ def _rows(panes):
643
+ """(key, row) pairs from tmux pane rows, or from a mapping {pane key: pane pid | row}."""
644
+ if isinstance(panes, dict):
645
+ for key, value in panes.items():
646
+ yield str(key), (value if isinstance(value, dict) else {'pane_pid': value})
647
+ else:
648
+ for row in panes or ():
649
+ if isinstance(row, dict):
650
+ yield pane_key(row), row
651
+
652
+
653
+ def _verdict(status, name, session_id, pid, since, source, **extra):
654
+ verdict = dict(zip(VERDICT_KEYS, (HARNESS, status, name, session_id, pid, since, source)))
655
+ verdict.update(extra)
656
+ return verdict
657
+
658
+
659
+ def _from_title(parsed, pid, since, bell):
660
+ status = 'done' if parsed['status'] == 'idle' and bell else parsed['status']
661
+ return _verdict(status, parsed['name'], '', pid, since, SOURCE_TITLE)
662
+
663
+
664
+ def _observe(home, row, parsed, procs, complete, pane_pids):
665
+ since = _int(row.get('window_activity'))
666
+ bell = row.get('window_bell_flag') == '1'
667
+ named = parsed['name'] if parsed else ''
668
+ builds = [pid for pid, flavour in procs if flavour == HARNESS]
669
+ unread = [pid for pid, flavour in procs if flavour == FLAVOUR_UNREADABLE]
670
+ if not procs:
671
+ if parsed is None:
672
+ return None
673
+ if complete:
674
+ return _verdict('shell', named, '', 0, since, SOURCE_TITLE)
675
+ return _from_title(parsed, 0, since, bell)
676
+ if not builds and not unread:
677
+ return None # grok-cli only: nothing a host can read (GROK_CLI)
678
+ pid = (builds or unread)[0]
679
+ leader = home.leader()
680
+ if leader:
681
+ return _verdict(UNKNOWN, named, '', pid, since, SOURCE_FILE,
682
+ note=f'leader mode: $GROK_HOME/{leader} exists; sessions are not joined to panes')
683
+ entries = home.entries()
684
+ mine = [entry for entry in entries if entry['pid'] == pid]
685
+ if not mine and builds:
686
+ cwd = _proc_link(home.proc_root, pid, 'cwd')
687
+ foreign = [entry for entry in entries if entry['pid'] not in pane_pids and cwd and entry['cwd'] == cwd]
688
+ if foreign:
689
+ return _verdict(UNKNOWN, named, '', pid, since, SOURCE_FILE,
690
+ note=f"leader mode: active_sessions.json names pid {foreign[0]['pid']} for this cwd, "
691
+ f'not the pane grok {pid}')
692
+ if len(mine) == 1:
693
+ observed = home.session(mine[0])
694
+ if observed is not None:
695
+ state, title = observed
696
+ status = state['status']
697
+ if status == 'needs_input' and state['outcome'] == 'completed' and bell:
698
+ status = 'done'
699
+ when = state['since'] or _int(mine[0]['opened_at']) or since
700
+ return _verdict(status, title or named, mine[0]['session_id'], pid, when, SOURCE_FILE)
701
+ return _from_title(parsed, pid, since, bell) if parsed else None
702
+
703
+
704
+ def grok_sessions(root, panes, grok_home=GROK_HOME_DEFAULT, *, home=CONTAINER_HOME, titles=None):
705
+ """{pane key: verdict} for the panes Grok Build runs in (or ended in), read through `root`, the container's root as
706
+ the host sees it (`/proc/<init pid>/root`; its /proc is `root/proc`, the container's pid namespace).
707
+
708
+ `panes` is the container's tmux pane rows (`session_name`, `window_id`, `pane_id`, `pane_pid`, and when present
709
+ `pane_title`, `window_bell_flag`, `window_activity`, `pane_current_command`, `harness`) or a mapping {pane key:
710
+ pane pid | row}. A row with a title is looked at only when the title is Grok's, tmux names `grok` its foreground
711
+ command, or the row's `harness` is 'grok'; a pid-only row always is. `grok_home` is relative to `home` unless
712
+ absolute. `titles`, when given, is the caller's memory between calls ({pane key: last title}, holding a pane only
713
+ while its title carries the Action Required prefix): the blink rule. One dict per container."""
714
+ proc_root = os.path.join(str(root), 'proc')
715
+ candidates = []
716
+ for key, row in _rows(panes):
717
+ has_title = 'pane_title' in row
718
+ title = row.get('pane_title')
719
+ parsed = parse_grok_title(title, titles.get(key) if titles is not None else None) if has_title else None
720
+ if titles is not None:
721
+ if _prefixed(title):
722
+ titles[key] = _clean_title(title)
723
+ else:
724
+ titles.pop(key, None)
725
+ if has_title and parsed is None and _command(row) != COMM and row.get('harness') != HARNESS:
726
+ continue
727
+ pid = _int(row.get('pane_pid'))
728
+ procs, complete = pane_grok_pids(proc_root, pid) if pid > 0 else ([], False)
729
+ candidates.append((key, row, parsed, procs, complete))
730
+ if not candidates:
731
+ return {}
732
+ pane_pids = {pid for *_head, procs, _complete in candidates for pid, flavour in procs if flavour != CLI_HARNESS}
733
+ grok = _GrokHome(root, home, grok_home, proc_root)
734
+ out = {}
735
+ try:
736
+ for key, row, parsed, procs, complete in candidates:
737
+ verdict = _observe(grok, row, parsed, procs, complete, pane_pids)
738
+ if verdict is not None:
739
+ out[key] = verdict
740
+ finally:
741
+ grok.close()
742
+ return out