@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.
- package/package.json +1 -1
- package/payload/lanes/README.md +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +790 -31
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +13 -7
- package/payload/lanes/container/lane-mcp +56 -17
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +346 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +165 -14
- package/payload/lanes/docs/DESIGN.md +7 -0
- package/payload/lanes/docs/INSTALL.md +23 -3
- package/payload/lanes/docs/RUNBOOK.md +392 -4
- package/payload/lanes/docs/USERGUIDE.md +222 -23
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/sidebar.py +522 -14
- package/payload/lanes/libexec/statusd.py +434 -28
- package/payload/lanes/libexec/tokens.py +12 -1
- package/payload/lanes/libexec/update.py +78 -5
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/manifest.json +4 -3
|
@@ -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
|