nexor-runner 0.2.0__py3-none-any.whl

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,3 @@
1
+ """nexor-runner: hosts and runs AI coding agents in tmux sessions."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,477 @@
1
+ """Per-session activity: is the agent working, idle, or blocked on an approval prompt?
2
+
3
+ Everything here is read-only and cheap. Per runtime:
4
+
5
+ - Claude Code: the transcript `<claude>/projects/<cwd-slug>/<session-id>.jsonl` is append-only;
6
+ only its tail is read, incrementally (a stored offset, so an unchanged file costs one `stat`).
7
+ - Codex: the rollout `<codex>/sessions/YYYY/MM/DD/rollout-<ts>-<thread-id>.jsonl`, same method.
8
+ - OpenCode: three GETs to the local engine (`/api/session/active`, `/api/session/<id>`,
9
+ `/api/session/<id>/permission`), answered from a short cache.
10
+ - A tmux pane capture (one per session that looks like it is working, plus the sessions that have
11
+ no readable source) adds what the files cannot say: a visible approval menu means `waiting`,
12
+ `esc to interrupt` means `working`.
13
+
14
+ The result per session is `activity` (`working` | `idle` | `waiting` | `unknown`),
15
+ `activity_since` (ISO 8601 UTC, or null) and `last_turn_end` (ISO 8601 UTC, or null).
16
+ Nothing here raises into the caller: a source that cannot be read is `unknown`.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import json
22
+ import re
23
+ import sys
24
+ import threading
25
+ import time
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+ from collections.abc import Callable, Mapping
30
+ from dataclasses import dataclass
31
+ from datetime import UTC, datetime
32
+ from pathlib import Path
33
+
34
+ from . import opencode_service
35
+ from .tmux import Tmux, TmuxError
36
+
37
+ WORKING, IDLE, WAITING, UNKNOWN = "working", "idle", "waiting", "unknown"
38
+ ACTIVITIES = (WORKING, IDLE, WAITING, UNKNOWN)
39
+
40
+ WINDOWS = (256 << 10, 2 << 20) # first look at a file: its last 256 KiB, then 2 MiB if undecided
41
+ MAX_CHUNK = 1 << 20 # never read more than this from one file in one go
42
+ PANE_TTL = 2.0 # seconds a pane capture is reused
43
+ ENGINE_TTL = 3.0 # seconds the engine's answers are reused (active set, permissions)
44
+ SESSION_TTL = 10.0 # seconds an engine session record (time.idle) is reused
45
+ URL_TTL = 60.0 # seconds the engine URL is reused (`opencode service status` is a subprocess)
46
+ URL_FAIL_TTL = 10.0
47
+ MISS_TTL = 15.0 # seconds before looking again for a transcript/rollout that was not found
48
+ LIST_TTL = 30.0 # seconds the engine session list (title lookup) is reused
49
+ ENGINE_TIMEOUT = 2.0
50
+ PANE_LINES = 30
51
+
52
+ WORKING_PANE_RE = re.compile(r"esc to interrupt", re.IGNORECASE)
53
+ # The numbered approval menu of Claude Code and Codex: `❯ 1. Yes ...`, `› 1. Yes, proceed (y)`.
54
+ WAITING_PANE_RE = re.compile(r"^[ \t]*(?:[❯›>][ \t]*)?1\.[ \t]+Yes\b", re.MULTILINE)
55
+ SKIP_PREFIXES = ("<local-command-", "<command-name>", "<command-message>")
56
+
57
+
58
+ @dataclass
59
+ class Act:
60
+ """What a source says. `since` None = the source cannot say when it began."""
61
+ activity: str = UNKNOWN
62
+ since: str | None = None
63
+ last_end: str | None = None
64
+ compacted: bool = False # reader scratch: a compaction boundary was just seen
65
+
66
+
67
+ def iso(value: object) -> str | None:
68
+ """ISO 8601 UTC with milliseconds from an ISO string or an epoch in milliseconds."""
69
+ try:
70
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
71
+ dt = datetime.fromtimestamp(value / 1000, UTC)
72
+ elif isinstance(value, str) and value:
73
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
74
+ if dt.tzinfo is None:
75
+ dt = dt.replace(tzinfo=UTC)
76
+ else:
77
+ return None
78
+ except (ValueError, OverflowError, OSError):
79
+ return None
80
+ return dt.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
81
+
82
+
83
+ def classify_pane(text: str) -> str | None:
84
+ """`waiting` when an approval menu is on screen, `working` when the client says it can be
85
+ interrupted, else None. Only the bottom of the pane counts (the menu sits there)."""
86
+ tail = "\n".join(text.splitlines()[-PANE_LINES:])
87
+ if WAITING_PANE_RE.search(tail):
88
+ return WAITING
89
+ if WORKING_PANE_RE.search(tail):
90
+ return WORKING
91
+ return None
92
+
93
+
94
+ # --- transcript / rollout readers ---------------------------------------------------------------
95
+
96
+ def _text_of(content: object) -> str:
97
+ if isinstance(content, str):
98
+ return content
99
+ if isinstance(content, list):
100
+ return "".join(b.get("text", "") for b in content
101
+ if isinstance(b, dict) and b.get("type") == "text")
102
+ return ""
103
+
104
+
105
+ def _end(st: Act, ts: str | None) -> None:
106
+ """A turn ended. Several records announce it (the closing assistant entry, then the turn
107
+ summary): the first one counts."""
108
+ if st.activity != IDLE:
109
+ st.activity, st.since, st.last_end = IDLE, ts, ts
110
+
111
+
112
+ def claude_step(st: Act, o: dict) -> None:
113
+ """Fold one transcript entry into the state. Only conversation entries count: the file also
114
+ holds titles, hook output, attachments, cost and bridge records."""
115
+ if o.get("isSidechain"): # a subagent's own thread, not this session's turn
116
+ return
117
+ ts = iso(o.get("timestamp"))
118
+ kind, msg = o.get("type"), o.get("message")
119
+ compacted, st.compacted = st.compacted, False
120
+ if kind == "system" and o.get("subtype") == "compact_boundary":
121
+ st.compacted = True # the summary entry that follows is not a prompt
122
+ elif kind == "system" and o.get("subtype") == "turn_duration":
123
+ _end(st, ts) # written right after the closing assistant entry
124
+ elif kind == "assistant" and isinstance(msg, dict):
125
+ stop = msg.get("stop_reason")
126
+ if stop in ("end_turn", "stop_sequence", "max_tokens", "refusal"):
127
+ _end(st, ts)
128
+ else: # tool_use, or a block written before the stop reason is known
129
+ if st.activity != WORKING:
130
+ st.since = ts
131
+ st.activity = WORKING
132
+ elif kind == "user" and isinstance(msg, dict):
133
+ text = _text_of(msg.get("content")).lstrip()
134
+ if text.startswith("[Request interrupted by user"):
135
+ _end(st, ts)
136
+ elif (text.startswith(SKIP_PREFIXES) or o.get("isMeta") or o.get("isCompactSummary")
137
+ or compacted):
138
+ return # slash-command and compaction bookkeeping: not a turn
139
+ else: # a prompt or a tool result: the model has the floor
140
+ if st.activity != WORKING:
141
+ st.since = ts
142
+ st.activity = WORKING
143
+
144
+
145
+ def codex_step(st: Act, o: dict) -> None:
146
+ """Fold one rollout line into the state: turns open with `task_started` and close with
147
+ `task_complete` or `turn_aborted` (event_msg payloads)."""
148
+ if o.get("type") != "event_msg" or not isinstance(o.get("payload"), dict):
149
+ return
150
+ ts, kind = iso(o.get("timestamp")), o["payload"].get("type")
151
+ if kind == "task_started":
152
+ if st.activity != WORKING:
153
+ st.since = ts
154
+ st.activity = WORKING
155
+ elif kind in ("task_complete", "turn_aborted"):
156
+ _end(st, ts)
157
+
158
+
159
+ class Follower:
160
+ """Folds an append-only JSONL file into an `Act`, reading only what was appended.
161
+
162
+ The first look takes the tail of the file (a bigger window when the tail holds no turn
163
+ boundary yet); after that only bytes past the stored offset are read. A partial last line
164
+ is left for the next poll, and a file that shrank is read again from its tail."""
165
+
166
+ def __init__(self, path: Path, step: Callable[[Act, dict], None]):
167
+ self.path, self.step = path, step
168
+ self.offset: int | None = None
169
+ self.state = Act()
170
+
171
+ def _read(self, start: int, end: int, drop_first: bool) -> tuple[list[bytes], int]:
172
+ with self.path.open("rb") as fh:
173
+ fh.seek(start)
174
+ data = fh.read(end - start)
175
+ if drop_first: # `start` may be mid-line
176
+ cut = data.find(b"\n")
177
+ if cut < 0:
178
+ return [], end if end - start >= MAX_CHUNK else start
179
+ data, start = data[cut + 1:], start + cut + 1
180
+ last = data.rfind(b"\n")
181
+ if last < 0: # no complete line yet; a pathological one is skipped
182
+ return [], end if len(data) >= MAX_CHUNK else start
183
+ return data[:last].split(b"\n"), start + last + 1
184
+
185
+ def _fold(self, lines: list[bytes]) -> None:
186
+ for raw in lines:
187
+ if not raw.strip():
188
+ continue
189
+ try:
190
+ obj = json.loads(raw)
191
+ except ValueError:
192
+ continue
193
+ if isinstance(obj, dict):
194
+ self.step(self.state, obj)
195
+
196
+ def poll(self) -> Act:
197
+ size = self.path.stat().st_size
198
+ if self.offset is not None and size < self.offset:
199
+ self.offset, self.state = None, Act() # truncated or replaced
200
+ if self.offset is None:
201
+ for window in WINDOWS:
202
+ start = max(0, size - window)
203
+ lines, self.offset = self._read(start, size, drop_first=start > 0)
204
+ self.state = Act()
205
+ self._fold(lines)
206
+ if start == 0 and self.state.activity == UNKNOWN:
207
+ self.state.activity = IDLE # the whole file, and nobody has said anything
208
+ if self.state.activity != UNKNOWN:
209
+ break
210
+ elif size > self.offset:
211
+ start = max(self.offset, size - MAX_CHUNK)
212
+ lines, self.offset = self._read(start, size, drop_first=start > self.offset)
213
+ self._fold(lines)
214
+ return Act(self.state.activity, self.state.since, self.state.last_end)
215
+
216
+
217
+ # --- the tracker --------------------------------------------------------------------------------
218
+
219
+ class _Gone(Exception):
220
+ """The engine does not know this session (404)."""
221
+
222
+
223
+ def _slug(cwd: str) -> str:
224
+ return re.sub(r"[^A-Za-z0-9]", "-", cwd)
225
+
226
+
227
+ class ActivityTracker:
228
+ """Keeps the per-session caches and answers `annotate(rows)`. Thread-safe."""
229
+
230
+ def __init__(self, tmux: Tmux | None, env: Mapping[str, str],
231
+ clock: Callable[[], float] = time.time,
232
+ opener: Callable = urllib.request.urlopen,
233
+ log: Callable[[str], None] | None = None):
234
+ self.tmux, self.env, self.clock, self.opener = tmux, env, clock, opener
235
+ self.log = log or (lambda m: print(f"[activity] {m}", file=sys.stderr, flush=True))
236
+ self.lock = threading.RLock()
237
+ self.followers: dict[tuple[str, str], Follower | None] = {} # (client, id) -> follower
238
+ self.missed: dict[tuple[str, str], float] = {} # (client, id) -> when it was not found
239
+ self.seen: dict[str, tuple[str, str | None]] = {} # name -> (activity, since)
240
+ self.panes: dict[str, tuple[float, str | None]] = {}
241
+ self.engine: dict[str, tuple[float, object]] = {} # cache key -> (when, value)
242
+ self.warned: set[str] = set()
243
+
244
+ # -- public ----------------------------------------------------------------------------
245
+
246
+ def annotate(self, rows: list[dict]) -> None:
247
+ """Add `activity`, `activity_since` and `last_turn_end` to every row (`name`, `client`,
248
+ `session_id`, `handle`, `project`). Never raises."""
249
+ with self.lock:
250
+ now = self.clock()
251
+ for r in rows:
252
+ try:
253
+ act = self._one(r, now)
254
+ except (OSError, ValueError, KeyError, TypeError, AttributeError,
255
+ opencode_service.EngineError, TmuxError) as exc:
256
+ self._warn(f"{r.get('name')}: {type(exc).__name__}")
257
+ act = Act()
258
+ self._stamp(r, act, now)
259
+ alive = {r["name"] for r in rows}
260
+ for gone in [n for n in self.seen if n not in alive]:
261
+ del self.seen[gone]
262
+ self._prune({(r.get("client"), r.get("session_id")) for r in rows}, now)
263
+
264
+ # -- internals -------------------------------------------------------------------------
265
+
266
+ def _warn(self, what: str) -> None:
267
+ if what not in self.warned and len(self.warned) < 50: # once per distinct problem
268
+ self.warned.add(what)
269
+ self.log(f"cannot read activity: {what}")
270
+
271
+ def _prune(self, live: set[tuple[object, object]], now: float) -> None:
272
+ for cache in (self.followers, self.missed):
273
+ for key in [k for k in cache if k not in live]:
274
+ del cache[key]
275
+ for cache in (self.engine, self.panes): # entries nobody asked for in a while
276
+ for key in [k for k, v in cache.items() if now - v[0] > 600]:
277
+ del cache[key]
278
+
279
+ def _stamp(self, row: dict, act: Act, now: float) -> None:
280
+ since = act.since
281
+ if act.activity == UNKNOWN:
282
+ since = None
283
+ elif since is None: # the source cannot say: keep what was seen, else when it changed
284
+ prev = self.seen.get(row["name"])
285
+ since = prev[1] if prev and prev[0] == act.activity and prev[1] else iso(now * 1000)
286
+ self.seen[row["name"]] = (act.activity, since)
287
+ row["activity"], row["activity_since"], row["last_turn_end"] = (
288
+ act.activity, since, act.last_end)
289
+
290
+ def _one(self, row: dict, now: float) -> Act:
291
+ client = row.get("client")
292
+ if client == "cld":
293
+ act = self._file_act(row, now, "cld")
294
+ elif client == "cdx":
295
+ act = self._file_act(row, now, "cdx")
296
+ elif client == "oc":
297
+ return self._oc_act(row, now)
298
+ else:
299
+ return Act()
300
+ if act is None: # no transcript/rollout: the screen is all there is
301
+ pane = self._pane(row["name"], now)
302
+ return Act(pane) if pane in (WORKING, WAITING, IDLE) else Act()
303
+ if act.activity == WORKING and self._pane(row["name"], now) == WAITING:
304
+ return Act(WAITING, None, act.last_end)
305
+ return act
306
+
307
+ def _pane(self, name: str, now: float) -> str | None:
308
+ """`working` / `waiting` / `idle` (a readable pane that shows neither) / None (no pane)."""
309
+ hit = self.panes.get(name)
310
+ if hit and now - hit[0] < PANE_TTL:
311
+ return hit[1]
312
+ value = None
313
+ if self.tmux is not None:
314
+ try:
315
+ text = self.tmux.capture_tail(name, PANE_LINES)
316
+ except TmuxError:
317
+ text = ""
318
+ value = classify_pane(text) or ("idle" if text.strip() else None)
319
+ self.panes[name] = (now, value)
320
+ return value
321
+
322
+ # -- Claude Code and Codex: files ------------------------------------------------------
323
+
324
+ def _claude_path(self, sid: str, cwd: str | None) -> Path | None:
325
+ home = Path(self.env.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
326
+ projects = home / "projects"
327
+ if cwd:
328
+ direct = projects / _slug(cwd) / f"{sid}.jsonl"
329
+ if direct.is_file():
330
+ return direct
331
+ return next(iter(projects.glob(f"*/{sid}.jsonl")), None)
332
+
333
+ def _codex_path(self, sid: str) -> Path | None:
334
+ home = Path(self.env.get("CODEX_HOME") or Path.home() / ".codex")
335
+ for f in (home / "sessions").glob(f"*/*/*/rollout-*-{sid}.jsonl"):
336
+ if self._guardian(f):
337
+ continue
338
+ return f
339
+ return None
340
+
341
+ @staticmethod
342
+ def _guardian(path: Path) -> bool:
343
+ """A guardian_review rollout is the approval reviewer's own thread, not a session."""
344
+ try:
345
+ with path.open("rb") as fh:
346
+ head = fh.read(65536).split(b"\n", 1)[0]
347
+ meta = json.loads(head).get("payload") or {}
348
+ except (OSError, ValueError, AttributeError):
349
+ return False
350
+ return meta.get("thread_source") == "guardian_review"
351
+
352
+ def _file_act(self, row: dict, now: float, client: str) -> Act | None:
353
+ sid = row.get("session_id")
354
+ if not isinstance(sid, str) or not sid or not re.fullmatch(r"[A-Za-z0-9_-]{8,64}", sid):
355
+ return None
356
+ key = (client, sid)
357
+ if key not in self.followers:
358
+ if now - self.missed.get(key, -1e9) < MISS_TTL:
359
+ return None
360
+ path = (self._claude_path(sid, row.get("project")) if client == "cld"
361
+ else self._codex_path(sid))
362
+ if path is None:
363
+ self.missed[key] = now
364
+ return None
365
+ self.followers[key] = Follower(path, claude_step if client == "cld" else codex_step)
366
+ follower = self.followers[key]
367
+ try:
368
+ return follower.poll()
369
+ except FileNotFoundError: # the file went away: look it up again next time
370
+ del self.followers[key]
371
+ self.missed[key] = now
372
+ return None
373
+
374
+ # -- OpenCode: the engine --------------------------------------------------------------
375
+
376
+ def _cached(self, key: str, ttl: float, now: float, fetch: Callable[[], object]) -> object:
377
+ hit = self.engine.get(key)
378
+ if hit and now - hit[0] < ttl:
379
+ return hit[1]
380
+ value = fetch()
381
+ self.engine[key] = (now, value)
382
+ return value
383
+
384
+ def _engine_url(self, now: float) -> str:
385
+ hit = self.engine.get("url")
386
+ if hit and now - hit[0] < (URL_TTL if hit[1] else URL_FAIL_TTL):
387
+ if hit[1]:
388
+ return hit[1]
389
+ raise opencode_service.EngineError("the OpenCode engine is not running")
390
+ try:
391
+ url = opencode_service.status_url(self.env)
392
+ except opencode_service.EngineError:
393
+ url = None
394
+ self.engine["url"] = (now, url)
395
+ if not url:
396
+ raise opencode_service.EngineError("the OpenCode engine is not running")
397
+ return url
398
+
399
+ def _get(self, path: str, now: float) -> object:
400
+ down = self.engine.get("down")
401
+ if down and now - down[0] < URL_FAIL_TTL: # one timeout per window, not one per row
402
+ raise opencode_service.EngineError("the engine is not reachable")
403
+ url = self._engine_url(now)
404
+ pw = opencode_service.engine_password(self.env)
405
+ auth = "Basic " + base64.b64encode(f"opencode:{pw}".encode()).decode()
406
+ req = urllib.request.Request(url.rstrip("/") + path, headers={"Authorization": auth},
407
+ method="GET")
408
+ try:
409
+ with self.opener(req, timeout=ENGINE_TIMEOUT) as resp:
410
+ body = json.loads(resp.read() or b"null")
411
+ except urllib.error.HTTPError as exc:
412
+ if exc.code == 404:
413
+ raise _Gone from None
414
+ raise opencode_service.EngineError(f"the engine answered HTTP {exc.code}") from None
415
+ except (urllib.error.URLError, OSError, ValueError) as exc:
416
+ self.engine.pop("url", None) # it may have moved (the engine restarted)
417
+ self.engine["down"] = (now, True)
418
+ raise opencode_service.EngineError(
419
+ f"the engine is not reachable: {type(exc).__name__}") from None
420
+ return body.get("data") if isinstance(body, dict) else None
421
+
422
+ def _oc_resolve(self, row: dict, now: float) -> str | None:
423
+ """The engine session id: the row's own, else the newest engine session titled with the
424
+ handle (the runner titles its sessions with it; see oc_map)."""
425
+ sid = row.get("session_id")
426
+ if isinstance(sid, str) and sid.startswith("ses_") and re.fullmatch(r"[\w-]+", sid):
427
+ try:
428
+ self._cached(f"s:{sid}", SESSION_TTL, now,
429
+ lambda: self._get(f"/api/session/{sid}", now))
430
+ return sid
431
+ except _Gone:
432
+ self.engine.pop(f"s:{sid}", None)
433
+ handle = row.get("handle")
434
+ if not handle:
435
+ return None
436
+ listing = self._cached("list", LIST_TTL, now, lambda: opencode_service.list_sessions(
437
+ self.env, self._engine_url(now), timeout=ENGINE_TIMEOUT, opener=self.opener,
438
+ page=100, max_pages=1))
439
+ titled = [s for s in listing if s["title"] == handle]
440
+ return max(titled, key=lambda s: s["stamp"])["id"] if titled else None
441
+
442
+ def _oc_act(self, row: dict, now: float) -> Act:
443
+ sid = self._oc_resolve(row, now)
444
+ if sid is None:
445
+ return Act()
446
+ try:
447
+ rec = self._cached(f"s:{sid}", SESSION_TTL, now,
448
+ lambda: self._get(f"/api/session/{sid}", now))
449
+ active = self._cached("active", ENGINE_TTL, now,
450
+ lambda: self._get("/api/session/active", now))
451
+ blocked = self._cached(f"p:{sid}", ENGINE_TTL, now, lambda: self._blocked(sid, now))
452
+ except _Gone:
453
+ return Act()
454
+ idle = rec.get("time", {}).get("idle") if isinstance(rec, dict) else None
455
+ last_end = iso(idle)
456
+ if blocked:
457
+ return Act(WAITING, None, last_end)
458
+ if isinstance(active, dict) and sid in active:
459
+ return Act(WORKING, None, last_end)
460
+ if isinstance(active, dict):
461
+ if idle is not None and self.seen.get(row["name"], ("",))[0] in (WORKING, WAITING):
462
+ # just finished: the cached record predates the turn end
463
+ self.engine.pop(f"s:{sid}", None)
464
+ rec = self._cached(f"s:{sid}", SESSION_TTL, now,
465
+ lambda: self._get(f"/api/session/{sid}", now))
466
+ idle = rec.get("time", {}).get("idle") if isinstance(rec, dict) else idle
467
+ last_end = iso(idle)
468
+ return Act(IDLE, last_end, last_end)
469
+ return Act()
470
+
471
+ def _blocked(self, sid: str, now: float) -> bool:
472
+ for what in ("permission", "form"): # an approval request, or a question to the user
473
+ pending = self._get(f"/api/session/{sid}/{what}", now)
474
+ if isinstance(pending, list) and pending:
475
+ return True
476
+ return False
477
+