@vibelord/lanes 1.3.0 → 1.5.1
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 +797 -34
- 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 +182 -14
- package/payload/lanes/docs/DESIGN.md +7 -0
- package/payload/lanes/docs/INSTALL.md +23 -3
- package/payload/lanes/docs/RUNBOOK.md +402 -5
- package/payload/lanes/docs/USERGUIDE.md +222 -23
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/attach.py +181 -0
- 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
|
@@ -27,9 +27,35 @@ child_liveness(): a per-pane read of /proc/<pid>/task/*/children (this kernel ex
|
|
|
27
27
|
MEASURED 2026-10-04 on 6.8.0-106-generic), falling back to one child_parents() /proc walk only when that read
|
|
28
28
|
fails; a pid whose liveness could not be determined gets no `pane_has_child` key (unknown never demotes).
|
|
29
29
|
pane_current_command stays a secondary hint (a non-shell pane root such as node is the harness itself, child or not).
|
|
30
|
+
|
|
31
|
+
W12 (spec `docs/superpowers/specs/2026-10-15-lanes-harness-observers-design.md` §2/§3): WHICH harness a pane runs is an
|
|
32
|
+
OBSERVED fact, no longer a side effect of parsing its title. `harness_walk` reads the pane's process tree in the
|
|
33
|
+
container's /proc (the `pane_has_child` reads, depth <= 4, breadth first) and takes the first `comm` in HARNESS_COMMS.
|
|
34
|
+
MEASURED 2026-10-15 on this workspace: Claude Code = `bash` -> `claude`; Codex = `bash` -> `node` (the npm wrapper) ->
|
|
35
|
+
`codex` (the native binary), so Codex is found one level down; Kimi Code 2.1.1 = `bash` -> `kimi-code` (exe
|
|
36
|
+
`~/.kimi-code/bin/kimi`, its /proc/<pid>/cwd equal to its session's state.json `cwd`). Grok Build and grok-cli both run
|
|
37
|
+
as `comm` `grok` and are told apart by /proc/<pid>/exe (evidence `2026-10-15-grok-build-measurement.md`); native Claude
|
|
38
|
+
Code before 2.1.299 runs with its version as `comm` (`2.1.295`, MEASURED 2026-10-15) and is named by its exe (DEF-1993).
|
|
39
|
+
Grok Build's own files are read by libexec/harness_grok.py (Track B) through `grok_sessions`, one contract. The Codex
|
|
40
|
+
title rule then runs only where Codex can be: before W12 any title of two or more words was read as Codex idle, so a
|
|
41
|
+
Kimi lane (static title `check the code and logs for thro`) was published `codex / idle` (DEF-1970).
|
|
42
|
+
|
|
43
|
+
Kimi Code keeps its state in files (`kimi_sessions`): `~/.kimi-code/sessions/wd_<name>_<sha256(root)[:12]>/
|
|
44
|
+
session_<uuid>/state.json` and the session's event log `agents/main/wire.jsonl`. MEASURED 2026-10-15 over the 24
|
|
45
|
+
sessions of this workspace (Kimi Code 2.1.1, protocol of state.json `version` 2): every event carries `time` in epoch
|
|
46
|
+
ms; a turn is `agent.turn.started` + `turn.prompt` ... `context.append_loop_event` (step.begin / tool.call /
|
|
47
|
+
tool.result / step.end / content.part) and `llm.request` ... `turn.ended{reason}` (completed 35, cancelled 9, failed 4)
|
|
48
|
+
then, after the turn's `agent.message.appended` dump, `agent.turn.ended{outcome: done}`; an approval is
|
|
49
|
+
`interaction.request{kind: approval}` answered by `interaction.resolved` of the same id (a `question` is the other kind,
|
|
50
|
+
and a cancelled turn can leave either unanswered); state.json drops `lastTurnReason` when a turn starts and sets it
|
|
51
|
+
(`completed|failed|cancelled`) when one ends.
|
|
30
52
|
"""
|
|
53
|
+
import importlib.util
|
|
31
54
|
import json
|
|
32
55
|
import os
|
|
56
|
+
import stat
|
|
57
|
+
import sys
|
|
58
|
+
import time
|
|
33
59
|
from pathlib import Path
|
|
34
60
|
|
|
35
61
|
SPINNER = set('⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠁⠂⠄⡀⢀⠠⠐⠈')
|
|
@@ -48,6 +74,37 @@ RANK = {'needs': 3, 'working': 2, 'done': 1, None: 0}
|
|
|
48
74
|
DWELL = {'needs': 8, 'working': 5, 'done': 5, None: 0}
|
|
49
75
|
GONE_DWELL = 5
|
|
50
76
|
|
|
77
|
+
# W12 harness identity (spec §2). The comm (/proc/<pid>/comm, at most 15 bytes) of each harness, and what it is called.
|
|
78
|
+
HARNESS_COMMS = {'claude': 'claude', 'codex': 'codex', 'kimi': 'kimi', 'kimi-code': 'kimi', 'grok': 'grok'}
|
|
79
|
+
AGENT_HARNESSES = ('claude', 'codex', 'kimi', 'grok', 'grok-cli')
|
|
80
|
+
HARNESSES = AGENT_HARNESSES + ('shell', 'unknown') # the published `agent.harness` enum (DM-LANES-STATUS)
|
|
81
|
+
HARNESS_DEPTH = 4 # levels below the pane's root process walked for a harness
|
|
82
|
+
HARNESS_FANOUT = 64 # processes examined per pane at most: a fork bomb under a pane costs a bounded read
|
|
83
|
+
# Grok Build's /proc/<pid>/exe is `$GROK_HOME/downloads/grok-linux-<arch>` (harness_grok.harness_of_exe, the same rule);
|
|
84
|
+
# any other process named grok is grok-cli.
|
|
85
|
+
GROK_BUILD_DIR, GROK_BUILD_PREFIX = 'downloads', 'grok-linux-'
|
|
86
|
+
# Native Claude Code runs `~/.local/share/claude/versions/<version>`; before 2.1.299 that file name, the version, was its
|
|
87
|
+
# comm (MEASURED 2026-10-15: `2.1.295`), so a process whose exe holds this is Claude Code whatever its comm (DEF-1993;
|
|
88
|
+
# lane-signal's and lane_core's CLAUDE_EXE, the same rule).
|
|
89
|
+
CLAUDE_EXE = '/claude/versions/'
|
|
90
|
+
|
|
91
|
+
# W12 Kimi Code observer (spec §3).
|
|
92
|
+
KIMI_SESSIONS = ('.kimi-code', 'sessions') # under the container home
|
|
93
|
+
KIMI_STALE = 3600 # a session whose newest event or state write is older than this makes no claim
|
|
94
|
+
KIMI_TAIL = 64 << 10 # bytes of agents/main/wire.jsonl read from its end (whole lines only)
|
|
95
|
+
KIMI_STATE_MAX = 256 << 10 # a bigger state.json is not read (MEASURED 2026-10-15: the largest of 24 is 41 KB)
|
|
96
|
+
KIMI_MAX_WORKDIRS = 256
|
|
97
|
+
KIMI_MAX_SESSIONS = 2048 # session directories looked at (one stat each) per container per cycle at most
|
|
98
|
+
KIMI_NAME_MAX = 120 # state.json `title` is the session's first prompt, cut: the published `name` is cut again
|
|
99
|
+
KIMI_ENDED = {'completed': 'needs_input', 'cancelled': 'needs_input', 'failed': 'errored'}
|
|
100
|
+
# W12 Grok Build observer (spec §4; libexec/harness_grok.py): Lanes runs Grok Build under GROK_HOME=~/.grok-build, a
|
|
101
|
+
# directory name relative to the container home (harness_grok.GROK_HOME_DEFAULT and CONTAINER_HOME, the same values).
|
|
102
|
+
GROK_HOME = '.grok-build'
|
|
103
|
+
CONTAINER_HOME = '/home/coder'
|
|
104
|
+
GROK_UNKNOWN = 'unknown' # the Grok observer's word for a pane it sees but cannot join (leader mode): no agent{}
|
|
105
|
+
_DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_CLOEXEC', 0)
|
|
106
|
+
_FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0)
|
|
107
|
+
|
|
51
108
|
|
|
52
109
|
def pane_has_child(proc_root, pid):
|
|
53
110
|
"""Does `pid` have any child process? Reads every /proc/<pid>/task/<tid>/children (a child is listed under
|
|
@@ -111,6 +168,147 @@ def child_liveness(proc_root, pids):
|
|
|
111
168
|
return out
|
|
112
169
|
|
|
113
170
|
|
|
171
|
+
def children_of(proc_root, pid):
|
|
172
|
+
"""The child pids of `pid` (every /proc/<pid>/task/<tid>/children, as `pane_has_child` reads them) as a frozenset,
|
|
173
|
+
or None when any of those reads fails — unknown, never "no children"."""
|
|
174
|
+
task = Path(proc_root, str(pid), 'task')
|
|
175
|
+
try:
|
|
176
|
+
tids = os.listdir(task)
|
|
177
|
+
except OSError:
|
|
178
|
+
return None
|
|
179
|
+
if not tids:
|
|
180
|
+
return None
|
|
181
|
+
kids = set()
|
|
182
|
+
for tid in tids:
|
|
183
|
+
try:
|
|
184
|
+
words = Path(task, tid, 'children').read_text().split()
|
|
185
|
+
except OSError:
|
|
186
|
+
return None
|
|
187
|
+
kids.update(int(word) for word in words if word.isdigit())
|
|
188
|
+
return frozenset(kids)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _comm(proc_root, pid):
|
|
192
|
+
try:
|
|
193
|
+
return Path(proc_root, str(pid), 'comm').read_text().strip() or None
|
|
194
|
+
except OSError:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _exe(proc_root, pid):
|
|
199
|
+
"""/proc/<pid>/exe without an auto-update's ` (deleted)` suffix, or None when it cannot be read."""
|
|
200
|
+
try:
|
|
201
|
+
exe = os.readlink(Path(proc_root, str(pid), 'exe'))
|
|
202
|
+
except OSError:
|
|
203
|
+
return None
|
|
204
|
+
return exe[:-len(' (deleted)')] if exe.endswith(' (deleted)') else exe
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _named(proc_root, pid, comm):
|
|
208
|
+
"""The harness a process whose comm is in HARNESS_COMMS runs. `grok` is Grok Build when /proc/<pid>/exe is
|
|
209
|
+
`…/downloads/grok-linux-<arch>` (harness_grok.harness_of_exe's rule), grok-cli for any other file; an unreadable
|
|
210
|
+
exe is `unknown`, never a guess (statusd still hands that pane to the Grok observer, which names it Grok Build only
|
|
211
|
+
when active_sessions.json names its pid)."""
|
|
212
|
+
harness = HARNESS_COMMS[comm]
|
|
213
|
+
if harness != 'grok':
|
|
214
|
+
return harness
|
|
215
|
+
exe = _exe(proc_root, pid)
|
|
216
|
+
if exe is None:
|
|
217
|
+
return 'unknown'
|
|
218
|
+
parent, _slash, name = exe.rpartition('/')
|
|
219
|
+
return 'grok' if name.startswith(GROK_BUILD_PREFIX) and parent.rpartition('/')[2] == GROK_BUILD_DIR else 'grok-cli'
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _harness_of(proc_root, pid, comm):
|
|
223
|
+
"""The harness process `pid` (whose comm is `comm`) is, or None: a comm in HARNESS_COMMS (`_named`), else — for a
|
|
224
|
+
process that is not a shell — native Claude Code by its executable (CLAUDE_EXE, DEF-1993). A shell's exe is its
|
|
225
|
+
shell, so shells cost no readlink."""
|
|
226
|
+
if comm in HARNESS_COMMS:
|
|
227
|
+
return _named(proc_root, pid, comm)
|
|
228
|
+
if comm is None or comm.lstrip('-') in SHELL_COMMANDS:
|
|
229
|
+
return None
|
|
230
|
+
exe = _exe(proc_root, pid)
|
|
231
|
+
return 'claude' if exe is not None and CLAUDE_EXE in exe else None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def harness_walk(proc_root, pid, kids=None):
|
|
235
|
+
"""(harness, harness pid) of the pane whose root process is `pid`, read in `proc_root` (the container's /proc as
|
|
236
|
+
the host sees it). Breadth first from the root itself down HARNESS_DEPTH levels of children, the first process
|
|
237
|
+
whose comm is in HARNESS_COMMS names it (`_named`), so a harness that runs another as a tool is still the lane's
|
|
238
|
+
harness. A process whose comm is not a harness's but whose executable is a native Claude Code file (CLAUDE_EXE:
|
|
239
|
+
before 2.1.299 its comm was its version) is Claude Code (`_harness_of`, DEF-1993). No harness found: `shell` when
|
|
240
|
+
every process read is a shell (SHELL_COMMANDS) and the tree ended within the depth, else `unknown` (another
|
|
241
|
+
program, a comm or children read that failed, the depth or HARNESS_FANOUT cut) with harness pid 0. (None, 0) when
|
|
242
|
+
the root's own children cannot be read: the harness was not observed at all. `kids` = the root's children when the
|
|
243
|
+
caller has read them already (`HarnessCache`)."""
|
|
244
|
+
kids = children_of(proc_root, pid) if kids is None else kids
|
|
245
|
+
if kids is None:
|
|
246
|
+
return None, 0
|
|
247
|
+
comm = _comm(proc_root, pid)
|
|
248
|
+
found = _harness_of(proc_root, pid, comm)
|
|
249
|
+
if found:
|
|
250
|
+
return found, int(pid)
|
|
251
|
+
only_shells = comm is not None and comm.lstrip('-') in SHELL_COMMANDS
|
|
252
|
+
level, seen = sorted(kids), 0
|
|
253
|
+
for _depth in range(HARNESS_DEPTH):
|
|
254
|
+
below = []
|
|
255
|
+
for child in level:
|
|
256
|
+
seen += 1
|
|
257
|
+
if seen > HARNESS_FANOUT:
|
|
258
|
+
return 'unknown', 0
|
|
259
|
+
name = _comm(proc_root, child)
|
|
260
|
+
found = _harness_of(proc_root, child, name)
|
|
261
|
+
if found:
|
|
262
|
+
return found, child
|
|
263
|
+
if name is None or name.lstrip('-') not in SHELL_COMMANDS:
|
|
264
|
+
only_shells = False
|
|
265
|
+
grand = children_of(proc_root, child)
|
|
266
|
+
if grand is None:
|
|
267
|
+
only_shells = False
|
|
268
|
+
else:
|
|
269
|
+
below.extend(sorted(grand))
|
|
270
|
+
level = below
|
|
271
|
+
if not level:
|
|
272
|
+
break
|
|
273
|
+
return ('shell' if only_shells and not level else 'unknown'), 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class HarnessCache:
|
|
277
|
+
"""statusd's per-pane memory of `harness_walk`, keyed by (proc root, pane pid): each cycle reads the pane's own
|
|
278
|
+
children and the comm of the harness it found; the tree is walked again when that child set changed, when that
|
|
279
|
+
process exited or its comm is no longer the one it had when it was found (an exec in place — compared as the comm
|
|
280
|
+
itself, so a native Claude Code named by its version, whose comm is no HARNESS_COMMS key, is re-walked too), or
|
|
281
|
+
when the last answer was not a harness (`shell`/`unknown` are never kept: a wrapper such as Codex's `node` starts
|
|
282
|
+
its harness one level down without changing the pane's own children). `prune` drops the panes a cycle did not
|
|
283
|
+
see."""
|
|
284
|
+
|
|
285
|
+
def __init__(self):
|
|
286
|
+
self.entries = {}
|
|
287
|
+
self.walks = 0 # how many trees were walked (the tests' view of a cache hit)
|
|
288
|
+
|
|
289
|
+
def harness(self, proc_root, pid):
|
|
290
|
+
key = (str(proc_root), int(pid))
|
|
291
|
+
kids = children_of(proc_root, pid)
|
|
292
|
+
if kids is None:
|
|
293
|
+
self.entries.pop(key, None)
|
|
294
|
+
return None, 0
|
|
295
|
+
hit = self.entries.get(key)
|
|
296
|
+
if hit and hit[0] == kids and hit[3] is not None and _comm(proc_root, hit[2]) == hit[3]:
|
|
297
|
+
return hit[1], hit[2]
|
|
298
|
+
self.walks += 1
|
|
299
|
+
harness, harness_pid = harness_walk(proc_root, pid, kids)
|
|
300
|
+
comm = _comm(proc_root, harness_pid) if harness_pid else None
|
|
301
|
+
if harness in AGENT_HARNESSES and harness_pid and comm is not None:
|
|
302
|
+
self.entries[key] = (kids, harness, harness_pid, comm)
|
|
303
|
+
else:
|
|
304
|
+
self.entries.pop(key, None)
|
|
305
|
+
return harness, harness_pid
|
|
306
|
+
|
|
307
|
+
def prune(self, keep):
|
|
308
|
+
for key in set(self.entries) - set(keep):
|
|
309
|
+
del self.entries[key]
|
|
310
|
+
|
|
311
|
+
|
|
114
312
|
def _harness_ended(pane_row):
|
|
115
313
|
"""True only when the row says the pane's root process has NO child (`pane_has_child is False`) and the
|
|
116
314
|
foreground is a bare shell: the harness exited into that shell, whatever title it left behind. A live
|
|
@@ -138,8 +336,15 @@ def _same_process(proc_root, pid, proc_start):
|
|
|
138
336
|
return False
|
|
139
337
|
|
|
140
338
|
|
|
141
|
-
def parse_codex_title(title):
|
|
142
|
-
"""A Codex pane title -> {'harness','status','name'}, or None for a shell / non-agent title.
|
|
339
|
+
def parse_codex_title(title, harness=None):
|
|
340
|
+
"""A Codex pane title -> {'harness','status','name'}, or None for a shell / non-agent title. `harness` is the pane's
|
|
341
|
+
OBSERVED harness (`harness_walk`): a pane running another harness (claude, kimi, grok, grok-cli) is never Codex's.
|
|
342
|
+
On a Codex pane any title is Codex's (a task line: idle); on a pane running something else (`unknown`) only the two
|
|
343
|
+
shapes nothing else draws — `[ ! ] Action Required` and a braille spinner — are; on a bare shell (`shell`, a title
|
|
344
|
+
tmux kept after Codex exited) and where the harness could not be observed (None) also Codex's idle `<task> | <dir>`.
|
|
345
|
+
The pre-W12 catch-all, "any title of two or more words is Codex idle", is gone (DEF-1970)."""
|
|
346
|
+
if harness not in (None, 'codex', 'unknown', 'shell'):
|
|
347
|
+
return None
|
|
143
348
|
t = (title or '').strip()
|
|
144
349
|
head = t.split('|')[0]
|
|
145
350
|
if t in SHELL_TITLES or (': ' in head and head.startswith(('coder@', 'root@'))):
|
|
@@ -151,7 +356,7 @@ def parse_codex_title(title):
|
|
|
151
356
|
return {'harness': 'codex', 'status': 'working', 'name': t[1:].split('|')[0].strip()}
|
|
152
357
|
if t.startswith('✳'):
|
|
153
358
|
return None # Claude's static title: presence only; the session file carries the state
|
|
154
|
-
if '|' in t
|
|
359
|
+
if harness == 'codex' or (harness != 'unknown' and '|' in t):
|
|
155
360
|
return {'harness': 'codex', 'status': 'idle', 'name': head.strip()}
|
|
156
361
|
return None
|
|
157
362
|
|
|
@@ -229,30 +434,417 @@ def read_sidecar_dict(d, now, max_age, proc_root=None):
|
|
|
229
434
|
'pid': pid, 'source': 'hook', 'event': str(d.get('event') or '')}
|
|
230
435
|
|
|
231
436
|
|
|
232
|
-
def
|
|
233
|
-
"""
|
|
437
|
+
def pane_key(pane_row):
|
|
438
|
+
"""`<session>:@<win>.%<pane>`, the key Claude's session files name a pane by and every per-pane index here uses."""
|
|
439
|
+
return f"{pane_row.get('session_name')}:{pane_row.get('window_id')}.{pane_row.get('pane_id')}"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _open_at(dir_fd, parts):
|
|
443
|
+
"""fd of <dir_fd>/<parts…>, every component a REAL directory opened with O_NOFOLLOW (a symlink is OSError, never
|
|
444
|
+
followed: through /proc/<pid>/root an absolute link would resolve against the HOST's root). `dir_fd` stays open;
|
|
445
|
+
the caller closes the returned fd. The `lanes_lib.open_real_dir` rule, kept here so agent_state imports nothing of
|
|
446
|
+
Lanes."""
|
|
447
|
+
fd = None
|
|
448
|
+
try:
|
|
449
|
+
for part in parts:
|
|
450
|
+
if not part or '/' in part or part in ('.', '..'):
|
|
451
|
+
raise OSError(f'not one directory entry: {part!r}')
|
|
452
|
+
child = os.open(part, _DIR_FLAGS | os.O_NOFOLLOW, dir_fd=dir_fd if fd is None else fd)
|
|
453
|
+
if fd is not None:
|
|
454
|
+
os.close(fd)
|
|
455
|
+
fd = child
|
|
456
|
+
except BaseException:
|
|
457
|
+
if fd is not None:
|
|
458
|
+
os.close(fd)
|
|
459
|
+
raise
|
|
460
|
+
return fd
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _mtime_at(dir_fd, name):
|
|
464
|
+
"""The mtime of `name` in `dir_fd` when it is a regular file (not followed), else None."""
|
|
465
|
+
try:
|
|
466
|
+
info = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
|
|
467
|
+
except OSError:
|
|
468
|
+
return None
|
|
469
|
+
return info.st_mtime if stat.S_ISREG(info.st_mode) else None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _read_at(dir_fd, name, limit, tail=False):
|
|
473
|
+
"""(bytes, whole) of `name` in `dir_fd` when it is a regular file, never a link (O_NOFOLLOW) and never blocking on a
|
|
474
|
+
FIFO (O_NONBLOCK), else None. Whole file: refused (None) above `limit` bytes. `tail`: its last `limit` bytes, read
|
|
475
|
+
from one byte earlier so the caller can tell a cut first line from a whole one (`wire_events`); `whole` is True
|
|
476
|
+
when that is the entire file."""
|
|
477
|
+
try:
|
|
478
|
+
fd = os.open(name, _FILE_FLAGS, dir_fd=dir_fd)
|
|
479
|
+
except OSError:
|
|
480
|
+
return None
|
|
481
|
+
try:
|
|
482
|
+
info = os.fstat(fd)
|
|
483
|
+
if not stat.S_ISREG(info.st_mode) or (info.st_size > limit and not tail):
|
|
484
|
+
return None
|
|
485
|
+
start = max(0, info.st_size - limit - 1) if tail else 0
|
|
486
|
+
if start:
|
|
487
|
+
os.lseek(fd, start, os.SEEK_SET)
|
|
488
|
+
want = limit + 1
|
|
489
|
+
chunks, size = [], 0
|
|
490
|
+
while size < want:
|
|
491
|
+
chunk = os.read(fd, want - size)
|
|
492
|
+
if not chunk:
|
|
493
|
+
break
|
|
494
|
+
chunks.append(chunk)
|
|
495
|
+
size += len(chunk)
|
|
496
|
+
data = b''.join(chunks)
|
|
497
|
+
if not tail and len(data) > limit:
|
|
498
|
+
return None # grew past the bound between fstat and read
|
|
499
|
+
return data, start == 0
|
|
500
|
+
except OSError:
|
|
501
|
+
return None
|
|
502
|
+
finally:
|
|
503
|
+
os.close(fd)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def wire_events(data, whole):
|
|
507
|
+
"""The events (JSON objects, file order) on the WHOLE lines of a wire.jsonl read: a tail that does not start at the
|
|
508
|
+
file's start loses everything up to its first newline (a cut line — or, when the byte before the window was a
|
|
509
|
+
newline, an empty one), and the text after the last newline is a line still being written. A line that is not a
|
|
510
|
+
JSON object is skipped."""
|
|
511
|
+
lines = data.split(b'\n')
|
|
512
|
+
lines = lines[:-1] if whole else lines[1:-1]
|
|
513
|
+
out = []
|
|
514
|
+
for line in lines:
|
|
515
|
+
try:
|
|
516
|
+
event = json.loads(line)
|
|
517
|
+
except ValueError:
|
|
518
|
+
continue
|
|
519
|
+
if isinstance(event, dict):
|
|
520
|
+
out.append(event)
|
|
521
|
+
return out
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _ms(value):
|
|
525
|
+
"""Epoch seconds from an epoch-ms field, or None."""
|
|
526
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
527
|
+
return None
|
|
528
|
+
return int(value // 1000)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def kimi_status(state, events, whole=False):
|
|
532
|
+
"""{'status', 'since', 'event'} of one Kimi Code session from its state.json (`state`, a dict) and the events of
|
|
533
|
+
its agents/main/wire.jsonl tail in file order (`wire_events`; `whole` = the tail is the entire file), or None.
|
|
534
|
+
|
|
535
|
+
Spec §3, as MEASURED (module docstring): `agent.turn.started` / `turn.prompt` open a turn -> working (since: the
|
|
536
|
+
turn's start); inside an open turn `context.append_loop_event` (tool.call among them), `llm.request` and
|
|
537
|
+
`turn.steer` keep it working (and are working on their own when the tail starts mid-turn); an
|
|
538
|
+
`interaction.request{kind: approval}` with no `interaction.resolved` of its id -> needs_approval (any other kind —
|
|
539
|
+
`question` — -> needs_input, a person is asked either way); `turn.ended{reason}` completed/cancelled ->
|
|
540
|
+
needs_input, failed -> errored; `agent.turn.ended{outcome}` the same (done -> needs_input) unless `turn.ended`
|
|
541
|
+
already ended that turn, and an end of a turn older than the one open is ignored; a turn start or end drops any
|
|
542
|
+
request left unanswered (a cancelled turn leaves its question so). With no such event in the tail: a whole file
|
|
543
|
+
without a turn is a session nobody has asked anything (idle); else state.json decides — `lastTurnReason` as
|
|
544
|
+
`turn.ended`'s reason, none (Kimi drops it when a turn starts) with a `lastPrompt` -> working, none without -> idle.
|
|
545
|
+
`since` is epoch seconds (the ms `time` floored), 0 when the event carried none."""
|
|
546
|
+
status = since = event = None
|
|
547
|
+
pending = {}
|
|
548
|
+
open_turn = None
|
|
549
|
+
ended = set()
|
|
550
|
+
turns = False
|
|
551
|
+
for item in events:
|
|
552
|
+
kind = item.get('type')
|
|
553
|
+
at = _ms(item.get('time'))
|
|
554
|
+
turn = item.get('turnId')
|
|
555
|
+
turn = None if turn is None or isinstance(turn, (dict, list)) else str(turn)
|
|
556
|
+
if kind in ('agent.turn.started', 'turn.prompt'):
|
|
557
|
+
turns = True
|
|
558
|
+
if status != 'working' or (turn is not None and open_turn is not None and turn != open_turn):
|
|
559
|
+
since, event = at, kind
|
|
560
|
+
status = 'working'
|
|
561
|
+
open_turn = turn if turn is not None else open_turn
|
|
562
|
+
pending.clear()
|
|
563
|
+
elif kind in ('context.append_loop_event', 'llm.request', 'turn.steer'):
|
|
564
|
+
turns = True
|
|
565
|
+
if status is None:
|
|
566
|
+
status, since, event = 'working', at, kind
|
|
567
|
+
elif kind == 'interaction.request':
|
|
568
|
+
turns = True
|
|
569
|
+
if item.get('id') is not None:
|
|
570
|
+
pending[str(item['id'])] = (str(item.get('kind') or ''), at)
|
|
571
|
+
elif kind == 'interaction.resolved':
|
|
572
|
+
pending.pop(str(item.get('id')), None)
|
|
573
|
+
elif kind in ('turn.ended', 'agent.turn.ended'):
|
|
574
|
+
turns = True
|
|
575
|
+
if turn is not None and ((open_turn is not None and turn != open_turn)
|
|
576
|
+
or (kind == 'agent.turn.ended' and turn in ended)):
|
|
577
|
+
continue
|
|
578
|
+
if kind == 'turn.ended':
|
|
579
|
+
word = KIMI_ENDED.get(item.get('reason'), 'needs_input')
|
|
580
|
+
if turn is not None:
|
|
581
|
+
ended.add(turn)
|
|
582
|
+
else:
|
|
583
|
+
word = 'errored' if item.get('outcome') in ('failed', 'error', 'errored') else 'needs_input'
|
|
584
|
+
status, since, event = word, at, kind
|
|
585
|
+
open_turn = None
|
|
586
|
+
pending.clear()
|
|
587
|
+
if pending:
|
|
588
|
+
approvals = [at for asked, at in pending.values() if asked == 'approval']
|
|
589
|
+
asked = approvals or [at for _kind, at in pending.values()]
|
|
590
|
+
stamps = [at for at in asked if at]
|
|
591
|
+
return {'status': 'needs_approval' if approvals else 'needs_input', 'since': min(stamps) if stamps else 0,
|
|
592
|
+
'event': 'interaction.request'}
|
|
593
|
+
if status:
|
|
594
|
+
return {'status': status, 'since': since or 0, 'event': event}
|
|
595
|
+
updated = _ms(state.get('updatedAt')) or 0
|
|
596
|
+
reason = state.get('lastTurnReason')
|
|
597
|
+
if whole and not turns:
|
|
598
|
+
word = 'idle'
|
|
599
|
+
elif isinstance(reason, str) and reason:
|
|
600
|
+
word = KIMI_ENDED.get(reason, 'needs_input')
|
|
601
|
+
else:
|
|
602
|
+
word = 'working' if state.get('lastPrompt') else 'idle'
|
|
603
|
+
return {'status': word, 'since': updated, 'event': 'state.json'}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _kimi_session(workdir_fd, name, cwds, now):
|
|
607
|
+
"""(activity, cwd, verdict without pid) of one `session_<uuid>` directory whose state.json `cwd` is in `cwds`, or
|
|
608
|
+
None: not ours, unreadable, or stale — neither file written within KIMI_STALE (two stats, nothing read) or its
|
|
609
|
+
newest content time (state.json `updatedAt`, the last event's `time`) older than that."""
|
|
610
|
+
try:
|
|
611
|
+
session_fd = os.open(name, _DIR_FLAGS | os.O_NOFOLLOW, dir_fd=workdir_fd)
|
|
612
|
+
except OSError:
|
|
613
|
+
return None
|
|
614
|
+
main_fd = None
|
|
615
|
+
try:
|
|
616
|
+
try:
|
|
617
|
+
main_fd = _open_at(session_fd, ('agents', 'main'))
|
|
618
|
+
except OSError:
|
|
619
|
+
main_fd = None
|
|
620
|
+
stamps = [m for m in (_mtime_at(session_fd, 'state.json'),
|
|
621
|
+
_mtime_at(main_fd, 'wire.jsonl') if main_fd is not None else None) if m]
|
|
622
|
+
if not stamps or now - max(stamps) > KIMI_STALE:
|
|
623
|
+
return None
|
|
624
|
+
got = _read_at(session_fd, 'state.json', KIMI_STATE_MAX)
|
|
625
|
+
if not got:
|
|
626
|
+
return None
|
|
627
|
+
try:
|
|
628
|
+
state = json.loads(got[0])
|
|
629
|
+
except ValueError:
|
|
630
|
+
return None
|
|
631
|
+
if not isinstance(state, dict) or state.get('archived') is True:
|
|
632
|
+
return None
|
|
633
|
+
cwd = state.get('cwd')
|
|
634
|
+
if not isinstance(cwd, str) or cwd not in cwds:
|
|
635
|
+
return None
|
|
636
|
+
events, whole = [], False
|
|
637
|
+
tail = _read_at(main_fd, 'wire.jsonl', KIMI_TAIL, tail=True) if main_fd is not None else None
|
|
638
|
+
if tail:
|
|
639
|
+
events, whole = wire_events(*tail), tail[1]
|
|
640
|
+
times = [t for t in [_ms(state.get('updatedAt'))] + [_ms(e.get('time')) for e in events[-1:]] if t]
|
|
641
|
+
activity = max(times) if times else max(stamps)
|
|
642
|
+
if now - activity > KIMI_STALE:
|
|
643
|
+
return None
|
|
644
|
+
found = kimi_status(state, events, whole)
|
|
645
|
+
sid = str(state.get('id') or name)
|
|
646
|
+
title = state.get('title')
|
|
647
|
+
verdict = {'harness': 'kimi', 'status': found['status'],
|
|
648
|
+
'name': title[:KIMI_NAME_MAX] if isinstance(title, str) else '',
|
|
649
|
+
'session_id': sid[len('session_'):] if sid.startswith('session_') else sid,
|
|
650
|
+
'since': found['since'], 'source': 'kimi-session-file', 'event': found['event']}
|
|
651
|
+
return activity, cwd, verdict
|
|
652
|
+
finally:
|
|
653
|
+
if main_fd is not None:
|
|
654
|
+
os.close(main_fd)
|
|
655
|
+
os.close(session_fd)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def kimi_sessions(root, panes, now=None, home=('home', 'coder')):
|
|
659
|
+
"""{pane key: agent verdict} for the panes running Kimi Code. `root` is the container's root as the host sees it
|
|
660
|
+
(`/proc/<init pid>/root`), `panes` {pane key: the pane's kimi pid} (statusd: the panes whose `harness_walk` found
|
|
661
|
+
`kimi`). Spec §3: a pane's kimi pid is joined to the session whose state.json `cwd` equals /proc/<pid>/cwd (read in
|
|
662
|
+
the container's /proc), the newest by activity (`_kimi_session`) when several are fresh; two kimi panes in one cwd
|
|
663
|
+
are AMBIGUOUS — neither is claimed, both fall back to the hooks, the title and the bell (DEF-1971). Every directory
|
|
664
|
+
from `home` down to `agents/main` is opened without following a link, every file read is bounded (state.json at
|
|
665
|
+
most KIMI_STATE_MAX bytes, wire.jsonl's last KIMI_TAIL), at most KIMI_MAX_WORKDIRS × KIMI_MAX_SESSIONS directories
|
|
666
|
+
are looked at, and a stale session (> KIMI_STALE) makes no claim. Verdict: {harness: kimi, status, name (state
|
|
667
|
+
title, cut), session_id (the session uuid), pid, since, source: kimi-session-file, event (the wire event that
|
|
668
|
+
decided, or `state.json`)}."""
|
|
669
|
+
now = time.time() if now is None else now
|
|
670
|
+
cwd_of = {}
|
|
671
|
+
for key, pid in (panes or {}).items():
|
|
672
|
+
try:
|
|
673
|
+
cwd_of[key] = os.readlink(Path(root, 'proc', str(int(pid)), 'cwd'))
|
|
674
|
+
except (OSError, ValueError, TypeError):
|
|
675
|
+
continue
|
|
676
|
+
by_cwd = {}
|
|
677
|
+
for key, cwd in cwd_of.items():
|
|
678
|
+
by_cwd.setdefault(cwd, []).append(key)
|
|
679
|
+
wanted = {cwd: keys[0] for cwd, keys in by_cwd.items() if len(keys) == 1}
|
|
680
|
+
if not wanted:
|
|
681
|
+
return {}
|
|
682
|
+
try:
|
|
683
|
+
root_fd = os.open(str(root), _DIR_FLAGS)
|
|
684
|
+
except OSError:
|
|
685
|
+
return {}
|
|
686
|
+
try:
|
|
687
|
+
top = _open_at(root_fd, tuple(home) + KIMI_SESSIONS)
|
|
688
|
+
except OSError:
|
|
689
|
+
return {}
|
|
690
|
+
finally:
|
|
691
|
+
os.close(root_fd)
|
|
692
|
+
best = {}
|
|
693
|
+
try:
|
|
694
|
+
budget = KIMI_MAX_SESSIONS
|
|
695
|
+
try:
|
|
696
|
+
workdirs = sorted(n for n in os.listdir(top) if n.startswith('wd_'))[:KIMI_MAX_WORKDIRS]
|
|
697
|
+
except OSError:
|
|
698
|
+
workdirs = []
|
|
699
|
+
for workdir in workdirs:
|
|
700
|
+
try:
|
|
701
|
+
workdir_fd = os.open(workdir, _DIR_FLAGS | os.O_NOFOLLOW, dir_fd=top)
|
|
702
|
+
except OSError:
|
|
703
|
+
continue
|
|
704
|
+
try:
|
|
705
|
+
try:
|
|
706
|
+
names = sorted(n for n in os.listdir(workdir_fd) if n.startswith('session_'))
|
|
707
|
+
except OSError:
|
|
708
|
+
names = []
|
|
709
|
+
for name in names:
|
|
710
|
+
if budget <= 0:
|
|
711
|
+
break
|
|
712
|
+
budget -= 1
|
|
713
|
+
found = _kimi_session(workdir_fd, name, wanted, now)
|
|
714
|
+
if found and (found[1] not in best or found[0] > best[found[1]][0]):
|
|
715
|
+
best[found[1]] = found
|
|
716
|
+
finally:
|
|
717
|
+
os.close(workdir_fd)
|
|
718
|
+
finally:
|
|
719
|
+
os.close(top)
|
|
720
|
+
out = {}
|
|
721
|
+
for cwd, (_activity, _cwd, verdict) in best.items():
|
|
722
|
+
key = wanted[cwd]
|
|
723
|
+
out[key] = dict(verdict, pid=int(panes[key]))
|
|
724
|
+
return out
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _harness_grok():
|
|
728
|
+
"""libexec/harness_grok.py (W12 Track B) when it ships, else None: LOOKED UP, not assumed, and imported only at the
|
|
729
|
+
call, so it may import this module."""
|
|
730
|
+
module = sys.modules.get('harness_grok')
|
|
731
|
+
if module is None:
|
|
732
|
+
if importlib.util.find_spec('harness_grok') is None:
|
|
733
|
+
return None
|
|
734
|
+
import harness_grok as module
|
|
735
|
+
return module
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def grok_sessions(root, panes, grok_home=GROK_HOME, *, home=CONTAINER_HOME, titles=None):
|
|
739
|
+
"""The Grok Build observer: `harness_grok.grok_sessions(root, panes, grok_home, home=home, titles=titles)` ->
|
|
740
|
+
{pane key: verdict}, or {} when the module does not ship. ONE contract, Track B's: `root` the container's root as the
|
|
741
|
+
host sees it, `panes` {pane key: the pane's tmux row} (statusd passes the rows it walked, so the observer has the
|
|
742
|
+
title, the bell and the observed `harness`; a bare pane pid works too), `grok_home` relative to `home` unless
|
|
743
|
+
absolute, `titles` the caller's memory between calls (statusd.GROK_TITLES: the blinking `⚠ Action Required`
|
|
744
|
+
prefix). An error inside the module is the caller's to log — statusd does. Kept: verdicts whose status is a
|
|
745
|
+
vocabulary word, and the observer's GROK_UNKNOWN (leader mode: a Grok Build it sees but cannot join to a session),
|
|
746
|
+
which `merge` publishes as no agent{} rather than a guess."""
|
|
747
|
+
if not panes:
|
|
748
|
+
return {}
|
|
749
|
+
module = _harness_grok()
|
|
750
|
+
if module is None:
|
|
751
|
+
return {}
|
|
752
|
+
got = module.grok_sessions(root, panes, grok_home, home=home, titles=titles)
|
|
753
|
+
if not isinstance(got, dict):
|
|
754
|
+
return {}
|
|
755
|
+
return {key: dict(verdict) for key, verdict in got.items()
|
|
756
|
+
if isinstance(verdict, dict) and (verdict.get('status') in STATUSES or verdict.get('status') == GROK_UNKNOWN)}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def grok_title(pane_row, bell, since):
|
|
760
|
+
"""merge's Grok title rung for a pane observed running Grok Build that no observer claimed:
|
|
761
|
+
`harness_grok.parse_grok_title` of its title (`⚠ Action Required …` needs_approval, a braille spinner working,
|
|
762
|
+
`grok` / `<name> - grok` idle — done with the bell), shaped as the observer's own title verdict (`source:
|
|
763
|
+
grok-title`), or None (no module, not a Grok title). Without the observer's title memory a blinking prefix is read
|
|
764
|
+
as it is this cycle."""
|
|
765
|
+
module = _harness_grok()
|
|
766
|
+
parse = getattr(module, 'parse_grok_title', None) if module is not None else None
|
|
767
|
+
parsed = parse(pane_row.get('pane_title')) if parse is not None else None
|
|
768
|
+
if not isinstance(parsed, dict) or parsed.get('status') not in STATUSES:
|
|
769
|
+
return None
|
|
770
|
+
try:
|
|
771
|
+
pid = int(pane_row.get('harness_pid') or 0)
|
|
772
|
+
except (TypeError, ValueError):
|
|
773
|
+
pid = 0
|
|
774
|
+
status = 'done' if parsed['status'] == 'idle' and bell else parsed['status']
|
|
775
|
+
return {'harness': 'grok', 'status': status, 'name': str(parsed.get('name') or ''), 'session_id': '',
|
|
776
|
+
'pid': max(pid, 0), 'since': since, 'source': 'grok-title'}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def merge(pane_row, claude_by_pane, sidecar, kimi_by_pane=None, grok_by_pane=None):
|
|
780
|
+
"""Precedence: hook sidecar (event-driven, every harness) > Claude session file > Kimi session file > Grok observer
|
|
781
|
+
(`grok_sessions`: its session file, else the title with its blink memory) > the Grok title (`grok_title`, a pane
|
|
782
|
+
observed as `grok` nothing above claimed) > Codex title > the observed harness itself > the bell.
|
|
234
783
|
Any Codex title — idle, spinner or Action Required, bell or not — over a bare-shell pane whose shell has no
|
|
235
784
|
child process (`pane_has_child is False`, statusd's row key) publishes `shell`: the harness ended and tmux kept
|
|
236
785
|
its last title, so no ghost idle/done/working/needs_approval. Unknown liveness leaves the title in charge.
|
|
237
|
-
|
|
786
|
+
|
|
787
|
+
W12: `pane_row['harness']` is the pane's OBSERVED harness (statusd, `HarnessCache`; absent = not observed). It gates
|
|
788
|
+
the Codex title (`parse_codex_title`) and, when it names a harness, is the verdict's `harness` whatever the signal
|
|
789
|
+
said (a Claude-installed hook firing inside another harness says `claude`). The Kimi observer's `needs_input` with
|
|
790
|
+
the window bell set is `done` — a turn ended and the harness rang — except a question still asked of the person
|
|
791
|
+
(`event: interaction.request`); the Grok observer's verdicts carry its own bell rule (only a completed turn or an
|
|
792
|
+
idle title is `done`) and are taken as they come. The Grok observer's GROK_UNKNOWN (leader mode: its Grok Build runs a session it
|
|
793
|
+
cannot join to the pane) publishes no agent{}: nothing below may guess one. Last rung: a pane whose observed harness
|
|
794
|
+
nothing above claimed is that harness, `idle`, `done` with the bell (`source: process`, `pid` the harness pid statusd
|
|
795
|
+
found). Returns None when nothing says an agent is here — the sidebar then draws a plain shell."""
|
|
796
|
+
observed = pane_row.get('harness')
|
|
797
|
+
verdict = _verdict(pane_row, claude_by_pane, sidecar, kimi_by_pane or {}, grok_by_pane or {}, observed)
|
|
798
|
+
if verdict is not None and observed in AGENT_HARNESSES:
|
|
799
|
+
verdict['harness'] = observed
|
|
800
|
+
return verdict
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _verdict(pane_row, claude_by_pane, sidecar, kimi_by_pane, grok_by_pane, observed):
|
|
238
804
|
if sidecar and sidecar.get('status') in STATUSES:
|
|
239
805
|
return dict(sidecar, source=sidecar.get('source') or 'hook')
|
|
240
|
-
key =
|
|
806
|
+
key = pane_key(pane_row)
|
|
241
807
|
if key in claude_by_pane:
|
|
242
808
|
return dict(claude_by_pane[key])
|
|
243
|
-
title = parse_codex_title(pane_row.get('pane_title'))
|
|
244
809
|
bell = pane_row.get('window_bell_flag') == '1'
|
|
810
|
+
# The Kimi observer leaves the bell to merge; the Grok observer applies it itself (harness_grok: only a completed
|
|
811
|
+
# turn or an idle title is `done` — a cancelled or interrupted turn stays needs_input, spec §4).
|
|
812
|
+
for index, rings in ((kimi_by_pane, True), (grok_by_pane, False)):
|
|
813
|
+
found = index.get(key)
|
|
814
|
+
if isinstance(found, dict) and found.get('status') == GROK_UNKNOWN:
|
|
815
|
+
return None # seen, not joinable (Grok's leader mode): no guess below
|
|
816
|
+
if isinstance(found, dict) and found.get('status') in STATUSES:
|
|
817
|
+
found = dict(found)
|
|
818
|
+
if rings and found['status'] == 'needs_input' and bell and found.get('event') != 'interaction.request':
|
|
819
|
+
found['status'] = 'done'
|
|
820
|
+
return found
|
|
821
|
+
try:
|
|
822
|
+
activity = int(pane_row.get('window_activity') or 0)
|
|
823
|
+
except ValueError:
|
|
824
|
+
activity = 0
|
|
825
|
+
if observed == 'grok':
|
|
826
|
+
found = grok_title(pane_row, bell, activity)
|
|
827
|
+
if found:
|
|
828
|
+
return found
|
|
829
|
+
title = parse_codex_title(pane_row.get('pane_title'), observed)
|
|
245
830
|
if title:
|
|
246
831
|
if _harness_ended(pane_row):
|
|
247
832
|
title['status'] = 'shell' # stale title on a childless shell: the harness ended, no ghost of any kind
|
|
248
833
|
elif title['status'] == 'idle' and bell:
|
|
249
834
|
title['status'] = 'done'
|
|
250
835
|
title['source'] = 'title'
|
|
251
|
-
|
|
252
|
-
title['since'] = int(pane_row.get('window_activity') or 0)
|
|
253
|
-
except ValueError:
|
|
254
|
-
title['since'] = 0
|
|
836
|
+
title['since'] = activity
|
|
255
837
|
return title
|
|
838
|
+
if observed in AGENT_HARNESSES:
|
|
839
|
+
found = {'harness': observed, 'status': 'done' if bell else 'idle', 'name': '', 'source': 'process',
|
|
840
|
+
'since': activity}
|
|
841
|
+
try:
|
|
842
|
+
harness_pid = int(pane_row.get('harness_pid') or 0)
|
|
843
|
+
except (TypeError, ValueError):
|
|
844
|
+
harness_pid = 0
|
|
845
|
+
if harness_pid > 0:
|
|
846
|
+
found['pid'] = harness_pid
|
|
847
|
+
return found
|
|
256
848
|
return None
|
|
257
849
|
|
|
258
850
|
|