Files
box/bin/agent-manager.py
T
operator 88ddc54a78 feat(tui): agent-manager tailnet agent-run dashboard
Read-only curses TUI (box-fleet-tui structure): per-device SSH scan of
tmux panes + process table, joined locally, runs sorted by agent type
across tailnet devices. Open harness taxonomy (muse/agy/known plus
other:<bin>), multi-socket enumeration, --once/--json dump mode.
2026-10-08 04:03:45 +00:00

906 lines
34 KiB
Python
Executable File

#!/usr/bin/env python3
"""agent-manager.py — Agent runs across tailnet devices (stdlib curses).
Read-only dashboard that aggregates agent harness runs (any CLI / bin
runtime: muse, agy, and whatever else matches the open taxonomy) over
SSH to every reachable tailnet device, then sorts by agent type.
Surfaces 3 read-only views (no actions in v1):
[1] RUNS — every run, sorted by agent type, then device.
[2] TYPES — counts per agent type with per-device breakdown.
[3] DEVICES — per-device reachability, run counts, and notes.
Structure mirrors bin/box-fleet-tui.py: all data-gathering lives in
pure, testable functions taking injected runners (see gather_*); the
curses UI is a thin renderer over those functions. The backend differs:
instead of local repo files, each refresh fans out over tailnet SSH
(one call per device: tmux panes + process table, joined locally).
Unreachable devices yield "n/a" rows — never a crash.
Usage:
python3 bin/agent-manager.py [--once [--json]] # non-interactive dump
"""
from __future__ import annotations
import concurrent.futures
import curses
import json
import os
import re
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
REPO_ROOT = Path(__file__).resolve().parent.parent
NA = "n/a"
RunFn = Callable[..., Tuple[int, str]]
# Per-device SSH budget; whole-fleet refresh runs devices in parallel.
DEVICE_TIMEOUT_S = 15
SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]
# Only these OS classes get an SSH probe (from `tailscale status`).
SSH_OS = ("linux", "macos")
# =====================================================================
# Agent-type taxonomy (open: unknown bin runtimes still show up)
# =====================================================================
#
# Classification keys off the argv[0] basename so a wrapper path never
# matters (/home/super/.local/bin/agy.bin -> agy). Scripts (*.py,
# *.sh) are never harness runtimes. Anything shaped like a bin runtime
# (*.bin, *-bin-*) that is not otherwise known shows under its own
# "other:<basename>" type instead of being dropped.
HARNESS_EXACT = {
"agy": "agy",
"agy.bin": "agy",
"muse-code": "muse",
"Muse": "muse",
"claude": "claude",
"codex": "codex",
"gemini": "gemini",
"aider": "aider",
"opencode": "opencode",
"crush": "crush",
"amp": "amp",
}
HARNESS_PREFIX = (
("muse-bin", "muse"),
)
# Bin-shaped names that are infrastructure, not agent harnesses.
HARNESS_DENY = frozenset({"tmux.bin", "tmux", "ssh.bin"})
SCRIPT_SUFFIXES = (".py", ".pyc", ".sh", ".pl", ".rb", ".js")
def classify_harness(argv0: str) -> Optional[Tuple[str, str]]:
"""Map an argv[0] to (agent_type, bin_name); None when not a harness.
Known harnesses collapse to a canonical type ("agy"); unknown bin
runtimes keep their own "other:<basename>" type so new harnesses
appear without a code change.
"""
base = os.path.basename((argv0 or "").strip().strip("'\""))
if not base:
return None
low = base.lower()
if low in HARNESS_DENY:
return None
if low.endswith(SCRIPT_SUFFIXES):
return None
if low in HARNESS_EXACT:
return HARNESS_EXACT[low], base
for prefix, typ in HARNESS_PREFIX:
if low.startswith(prefix):
return typ, base
if low.endswith(".bin") or "-bin-" in low or low.startswith("bin-"):
return "other:%s" % base, base
return None
def type_sort_key(agent_type: str) -> Tuple[int, str]:
"""Known types first (alpha), then other:* (alpha)."""
if agent_type.startswith("other:"):
return 1, agent_type
return 0, agent_type
# =====================================================================
# Default IO primitives (injectable seams for tests)
# =====================================================================
def _run(cmd: List[str], timeout: int = 15) -> Tuple[int, str]:
"""Run cmd, capture output. Returns (returncode, combined_output)."""
try:
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout)
return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip()
except subprocess.TimeoutExpired:
return 124, "timed out after %ds: %s" % (timeout, " ".join(cmd))
except OSError as e:
return 127, str(e)
def _run_ssh(device: str, remote_cmd: str,
timeout: int = DEVICE_TIMEOUT_S,
run: Optional[RunFn] = None) -> Tuple[int, str]:
"""Run one remote command over tailnet SSH. Fails closed, never raises."""
run = run or _run
try:
return run(["ssh"] + SSH_OPTS + [device, remote_cmd],
timeout=timeout)
except Exception as e:
return 127, str(e)
# =====================================================================
# Pure parsers
# =====================================================================
def parse_tailscale_status(out: str) -> List[Dict[str, Any]]:
"""Parse `tailscale status` into [{name, ip, os, online, detail}].
Unparseable lines are skipped; a warning preamble is ignored.
"""
devices: List[Dict[str, Any]] = []
for line in (out or "").splitlines():
line = line.rstrip()
if not line or line.startswith("Warning:"):
continue
parts = line.split()
if len(parts) < 4:
continue
ip, name, _user, osname = parts[0], parts[1], parts[2], parts[3]
if not re.match(r"^\d+\.\d+\.\d+\.\d+$", ip):
continue
detail = " ".join(parts[4:]) if len(parts) > 4 else ""
online = not detail.startswith("offline")
devices.append({"name": name, "ip": ip, "os": osname.lower(),
"online": online, "detail": detail or NA})
return devices
PANE_PREFIX = "PANE|"
PS_MARKER = "__PS__"
def parse_panes(out: str) -> List[Dict[str, Any]]:
"""Parse tmux pane lines (PANE|sock|id|pid|session|cmd|title).
Pane ids repeat across servers, so (socket, id) is the identity.
Session groups repeat a pane under several sessions; dedupe by
(socket, id), joining session names. The legacy 4-field shape
(no socket) still parses with sock="".
"""
seen: Dict[Tuple[str, str], Dict[str, Any]] = {}
order: List[Tuple[str, str]] = []
for line in (out or "").splitlines():
if not line.startswith(PANE_PREFIX):
continue
fields = line[len(PANE_PREFIX):].split("|")
if len(fields) >= 6:
sock, pane_id, pid_s, session, cmd = (
fields[0].strip(), fields[1].strip(), fields[2].strip(),
fields[3].strip(), fields[4].strip())
title = "|".join(fields[5:]).strip()
elif len(fields) >= 4:
sock, pane_id, pid_s, session, cmd = (
"", fields[0].strip(), fields[1].strip(),
fields[2].strip(), fields[3].strip())
title = "|".join(fields[4:]).strip()
else:
continue
try:
pid = int(pid_s)
except ValueError:
continue
session = session or NA
key = (sock, pane_id)
if key in seen:
prev = seen[key]["session"]
if session not in prev.split(","):
seen[key]["session"] = prev + "," + session
continue
seen[key] = {"sock": sock, "pane": pane_id, "pid": pid,
"session": session, "cmd": cmd, "title": title}
order.append(key)
return [seen[k] for k in order]
def display_session(run: Dict[str, Any]) -> str:
"""Session label; socket-qualified unless it is the default server."""
session = str(run.get("session", NA))
sock = run.get("sock") or ""
if session == NA or sock in ("", "default"):
return session
return "%s:%s" % (sock, session)
def parse_ps(out: str) -> Dict[int, Dict[str, Any]]:
"""Parse `ps -eo pid,ppid,etime,command` into {pid: rec}."""
procs: Dict[int, Dict[str, Any]] = {}
for line in (out or "").splitlines():
parts = line.split(None, 3)
if len(parts) < 4:
continue
try:
pid, ppid = int(parts[0]), int(parts[1])
except ValueError:
continue # header row
procs[pid] = {"pid": pid, "ppid": ppid, "etime": parts[2],
"args": parts[3]}
return procs
def split_scan(out: str) -> Tuple[str, str]:
"""Split a device scan into (pane_text, ps_text) at the marker."""
if PS_MARKER in out:
pane_text, _, ps_text = out.partition(PS_MARKER)
return pane_text, ps_text
return out, ""
def _descendants(procs: Dict[int, Dict[str, Any]],
root: int) -> List[int]:
"""Pids under root (breadth-first via ppid links)."""
kids: Dict[int, List[int]] = {}
for pid, rec in procs.items():
kids.setdefault(rec["ppid"], []).append(pid)
out: List[int] = []
queue = list(kids.get(root, []))
seen = {root}
while queue:
pid = queue.pop(0)
if pid in seen:
continue
seen.add(pid)
out.append(pid)
queue.extend(kids.get(pid, []))
return out
def join_runs(panes: List[Dict[str, Any]],
procs: Dict[int, Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Join tmux panes with harness processes into run records.
A pane is a run when its current command classifies as a harness
or a harness binary runs among its descendants. Harness processes
under no pane surface as bare runs (session/pane n/a). Returns
records sorted by (agent_type, session, pane).
"""
runs: List[Dict[str, Any]] = []
claimed: set = set()
pane_roots = {p["pid"] for p in panes}
for pane in panes:
argv = (pane.get("cmd") or "").strip()
pane_hit = classify_harness(argv.split()[0] if argv else "")
# Always resolve the live harness descendant: the pane's root
# is usually a shell, so its pid/etime/bin would mislead.
# The pane-command match is only a fallback (stale command).
hit = None
hpid: Optional[int] = None
for pid in _descendants(procs, pane["pid"]):
rec = procs.get(pid)
if not rec:
continue
first = rec["args"].split()[0] if rec["args"] else ""
hit = classify_harness(first)
if hit is not None:
hpid = pid
break
if hit is None:
if pane_hit is None:
continue
hit, hpid = pane_hit, pane["pid"]
agent_type, _bin = hit
claimed.add(hpid)
prec = procs.get(hpid, {})
if prec.get("args"):
binn = os.path.basename(prec["args"].split()[0])
elif argv:
binn = argv.split()[0]
else:
binn = NA
runs.append({
"type": agent_type,
"bin": binn,
"sock": pane.get("sock", ""),
"session": pane.get("session", NA),
"pane": pane.get("pane", NA),
"pid": hpid,
"etime": prec.get("etime", NA),
"title": (pane.get("title") or "")[:48],
})
# Bare harness processes (no tmux pane above them).
for pid, rec in procs.items():
if pid in claimed:
continue
first = rec["args"].split()[0] if rec["args"] else ""
hit = classify_harness(first)
if hit is None:
continue
# Skip when some pane root is an ancestor (already covered).
anc, under_pane = rec["ppid"], False
hops = 0
while anc in procs and hops < 64:
if anc in pane_roots:
under_pane = True
break
anc = procs[anc]["ppid"]
hops += 1
if under_pane:
continue
claimed.add(pid)
runs.append({
"type": hit[0],
"bin": os.path.basename(first),
"sock": "",
"session": NA,
"pane": NA,
"pid": pid,
"etime": rec.get("etime", NA),
"title": "",
})
runs.sort(key=lambda r: (type_sort_key(r["type"]),
str(r["session"]), str(r["pane"])))
return runs
# =====================================================================
# Surfaces: devices + runs
# =====================================================================
PANE_FORMAT = ("PANE|%s|#{pane_id}|#{pane_pid}|#{session_name}|"
"#{pane_current_command}|#{pane_title}")
REMOTE_SCAN_CMD = (
"for d in \"${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)\" "
"\"${TMPDIR:-/tmp}/tmux-$(id -u)\" /tmp/tmux-$(id -u); do "
"for s in \"$d\"/*; do [ -S \"$s\" ] || continue; "
"n=$(basename \"$s\"); "
"tmux -S \"$s\" list-panes -a -F \"PANE|$n|#{pane_id}|#{pane_pid}|"
"#{session_name}|#{pane_current_command}|#{pane_title}\" "
"2>/dev/null; done; done; "
"echo '%s'; ps -eo pid,ppid,etime,command 2>/dev/null" % PS_MARKER
)
def local_tmux_sockets() -> List[str]:
"""Absolute tmux socket paths on this machine (may be empty)."""
uid = os.getuid() if hasattr(os, "getuid") else 0
cands = []
for base in (os.environ.get("TMUX_TMPDIR") or "/tmp",
os.environ.get("TMPDIR") or "/tmp", "/tmp"):
cands.append(os.path.join(base, "tmux-%d" % uid))
found: List[str] = []
seen_dirs = set()
for d in cands:
if d in seen_dirs:
continue
seen_dirs.add(d)
try:
names = sorted(os.listdir(d))
except Exception:
continue
for n in names:
p = os.path.join(d, n)
try:
import stat
if stat.S_ISSOCK(os.stat(p).st_mode):
found.append(p)
except Exception:
continue
# Same server via two spellings: keep first per basename.
dedup: List[str] = []
seen_base = set()
for p in found:
b = os.path.basename(p)
if b not in seen_base:
seen_base.add(b)
dedup.append(p)
return dedup
def local_device_name() -> str:
"""Short hostname of this machine (never raises)."""
try:
import socket
return socket.gethostname().split(".")[0]
except Exception:
return "localhost"
def gather_devices(run: Optional[RunFn] = None,
local_name: Optional[str] = None) -> Dict[str, Any]:
"""Tailnet devices from `tailscale status` + the local machine.
Returns {"devices": [{name, ip, os, online, local, ssh, detail}],
"note": str}. Devices are stable-sorted: local first, then by name.
"ssh" marks whether v1 probes the device (online + ssh-capable OS).
"""
run = run or _run
local_name = local_name or local_device_name()
rc, out = run(["tailscale", "status"], timeout=10)
if rc != 0:
return {"devices": [{"name": local_name, "ip": NA, "os": NA,
"online": True, "local": True, "ssh": False,
"detail": "local only"}],
"note": "tailscale status failed (%s); local only."
% (out.strip().splitlines()[-1][:80] if out.strip()
else "rc=%d" % rc)}
devices = []
for d in parse_tailscale_status(out):
local = (d["name"] == local_name)
ssh = bool(d["online"] and d["os"] in SSH_OS and not local)
devices.append({"name": d["name"], "ip": d["ip"], "os": d["os"],
"online": d["online"], "local": local, "ssh": ssh,
"detail": d["detail"]})
if not any(d["local"] for d in devices):
devices.append({"name": local_name, "ip": NA, "os": NA,
"online": True, "local": True, "ssh": False,
"detail": "local"})
devices.sort(key=lambda d: (not d["local"], d["name"]))
return {"devices": devices, "note": ""}
def gather_device_runs(device: Dict[str, Any],
run: Optional[RunFn] = None) -> Dict[str, Any]:
"""One device scan -> {device, ok, runs, note}. Never raises."""
run = run or _run
name = device.get("name", "?")
try:
if device.get("local"):
pane_chunks = []
for sock in local_tmux_sockets():
rc1, chunk = run(
["tmux", "-S", sock, "list-panes", "-a", "-F",
PANE_FORMAT % os.path.basename(sock)],
timeout=DEVICE_TIMEOUT_S)
if rc1 == 0 and chunk:
pane_chunks.append(chunk)
_rc2, ps_out = run(["ps", "-eo", "pid,ppid,etime,command"],
timeout=DEVICE_TIMEOUT_S)
out = ("\n".join(pane_chunks) + "\n" + PS_MARKER + "\n"
+ ps_out)
else:
rc, out = _run_ssh(name, REMOTE_SCAN_CMD, run=run)
ssh_err = "" if rc == 0 else out.strip().splitlines()
ssh_err = ssh_err[-1][:100] if ssh_err else "rc=%d" % rc
if rc != 0:
return {"device": name, "ok": False, "runs": [],
"note": "ssh failed: %s" % ssh_err}
pane_text, ps_text = split_scan(out)
runs = join_runs(parse_panes(pane_text), parse_ps(ps_text))
for r in runs:
r["device"] = name
return {"device": name, "ok": True, "runs": runs, "note": ""}
except Exception as e:
return {"device": name, "ok": False, "runs": [],
"note": "scan failed: %s" % e}
def gather_all(run: Optional[RunFn] = None,
devices: Optional[List[Dict[str, Any]]] = None,
max_workers: int = 8) -> Dict[str, Any]:
"""One-shot snapshot: devices + runs sorted by agent type.
Devices scan in parallel (threads); each device is isolated — one
failure never blocks the rest. Returns {"devices": [...],
"runs": [...] (sorted by type/device), "by_type": {type: {total,
devices: {name: n}}}, "unreachable": [names], "note": str}.
"""
run = run or _run
dev_info = gather_devices(run=run)
if devices is None:
devices = [d for d in dev_info["devices"]
if d.get("local") or d.get("ssh")]
else:
devices = [d for d in devices
if d.get("local") or d.get("ssh")]
probed = {d["name"] for d in devices}
results: List[Dict[str, Any]] = []
if devices:
with concurrent.futures.ThreadPoolExecutor(
max_workers=min(max_workers, len(devices))) as pool:
futs = {pool.submit(gather_device_runs, d, run): d["name"]
for d in devices}
for fut in concurrent.futures.as_completed(futs):
try:
results.append(fut.result())
except Exception as e:
results.append({"device": futs[fut], "ok": False,
"runs": [], "note": "scan error: %s" % e})
runs: List[Dict[str, Any]] = []
unreachable: List[str] = []
for res in results:
if not res.get("ok"):
unreachable.append(res["device"])
continue
runs.extend(res.get("runs", []))
runs.sort(key=lambda r: (type_sort_key(r["type"]), r.get("device", ""),
str(r.get("session", ""))))
by_type: Dict[str, Dict[str, Any]] = {}
for r in runs:
bucket = by_type.setdefault(r["type"], {"total": 0, "devices": {}})
bucket["total"] += 1
dev = r.get("device", "?")
bucket["devices"][dev] = bucket["devices"].get(dev, 0) + 1
skipped = sorted(d["name"] for d in dev_info["devices"]
if d["name"] not in probed)
notes = [dev_info["note"]] if dev_info["note"] else []
if skipped:
notes.append("skipped (offline/mobile/key): %s" % ", ".join(skipped))
return {"devices": dev_info["devices"], "runs": runs,
"by_type": by_type, "unreachable": sorted(unreachable),
"note": "; ".join(notes)}
# =====================================================================
# Curses UI (thin read-only renderer over gather_*)
# =====================================================================
AUTO_REFRESH_S = 30.0
class AgentManagerTUI:
"""Read-only agent-run console. q quits, r refreshes, ? helps."""
def __init__(self, stdscr: "curses.window"):
self.stdscr = stdscr
self.current_tab = 0
self.tabs = [
"1: RUNS",
"2: TYPES",
"3: DEVICES",
]
try:
curses.curs_set(0)
except Exception:
pass
self.stdscr.nodelay(True)
self.stdscr.keypad(True)
if hasattr(curses, "set_escdelay"):
try:
curses.set_escdelay(25)
except Exception:
pass
self._init_colors()
self.scroll = 0
self.show_help = False
self.status_msg = "Scanning tailnet devices..."
self.last_refresh = 0.0
self.snapshot: Dict[str, Any] = {}
self.refresh()
# -- setup ------------------------------------------------------
def _init_colors(self) -> None:
try:
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_CYAN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, curses.COLOR_GREEN, -1)
curses.init_pair(4, curses.COLOR_RED, -1)
curses.init_pair(5, curses.COLOR_MAGENTA, -1)
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN)
curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW)
except Exception:
pass
def _attr(self, name: str) -> int:
try:
mapping = {
"normal": curses.color_pair(0),
"cyan": curses.color_pair(1) | curses.A_BOLD,
"yellow": curses.color_pair(2) | curses.A_BOLD,
"green": curses.color_pair(3) | curses.A_BOLD,
"red": curses.color_pair(4) | curses.A_BOLD,
"magenta": curses.color_pair(5) | curses.A_BOLD,
"head_sel": curses.color_pair(6) | curses.A_BOLD,
"row_sel": curses.color_pair(7) | curses.A_BOLD,
"warn": curses.color_pair(8) | curses.A_BOLD,
"dim": curses.A_DIM,
}
return mapping.get(name, 0)
except Exception:
return 0
# -- data -------------------------------------------------------
def refresh(self) -> None:
try:
self.snapshot = gather_all()
runs = len(self.snapshot.get("runs", []))
devs = len([d for d in self.snapshot.get("devices", [])
if d.get("local") or d.get("ssh")])
self.status_msg = (
"Snapshot %s: %d runs on %d devices "
"(auto-refresh %ds; r=refresh)" % (
datetime.now().strftime("%H:%M:%S"), runs, devs,
int(AUTO_REFRESH_S)))
except Exception as e:
self.snapshot = {}
self.status_msg = "Refresh failed (showing n/a): %s" % e
self.last_refresh = time.time()
self.scroll = 0
# -- render helpers ---------------------------------------------
def safe_addstr(self, y: int, x: int, text: str, attr: int = 0) -> None:
h, w = self.stdscr.getmaxyx()
if 0 <= y < h and 0 <= x < w:
try:
self.stdscr.addstr(y, x, text[:max(0, w - x - 1)], attr)
except Exception:
pass
def _render_header(self, w: int) -> None:
self.safe_addstr(0, 0, " " * w, self._attr("head_sel"))
title = " AGENT MANAGER (read-only) [agent-manager.py] "
self.safe_addstr(0, 1, title, self._attr("head_sel"))
self.safe_addstr(1, 0, " " * w, self._attr("dim"))
col = 1
for idx, tab_name in enumerate(self.tabs):
pill = " [%s] " % tab_name
attr = self._attr("head_sel") if idx == self.current_tab \
else self._attr("dim")
self.safe_addstr(1, col, pill, attr)
col += len(pill) + 1
self.safe_addstr(2, 0, "-" * w, self._attr("dim"))
def _render_footer(self, h: int, w: int) -> None:
self.safe_addstr(h - 2, 0, "-" * w, self._attr("dim"))
hints = " 1-3/Tab: Tabs j/k: Scroll r: Refresh ?: Help q: Quit"
self.safe_addstr(h - 1, 1, self.status_msg[: w - 2],
self._attr("dim"))
if len(self.status_msg) + len(hints) + 2 < w:
self.safe_addstr(h - 1, w - len(hints) - 1, hints,
self._attr("dim"))
def _body(self, h: int, w: int, title: str,
lines: List[Tuple[str, str]]) -> None:
self.safe_addstr(3, 2, title, self._attr("cyan"))
self.safe_addstr(4, 2, "-" * (w - 4), self._attr("dim"))
max_rows = h - 8
visible = lines[self.scroll:self.scroll + max_rows]
for i, (text, attr_name) in enumerate(visible):
self.safe_addstr(5 + i, 2, text, self._attr(attr_name))
if self.scroll > 0:
self.safe_addstr(5, w - 6, "^more", self._attr("dim"))
if self.scroll + max_rows < len(lines):
self.safe_addstr(h - 3, w - 6, "vmore", self._attr("dim"))
def _note_lines(self, w: int) -> List[Tuple[str, str]]:
note = self.snapshot.get("note", "")
unreach = self.snapshot.get("unreachable", [])
lines: List[Tuple[str, str]] = []
if unreach:
lines.append(("", "normal"))
lines.append(("unreachable: %s" % ", ".join(unreach),
"red"))
if note:
lines.append(("", "normal"))
lines.append(("note: %s" % note[: w - 10], "yellow"))
return lines
# -- per-tab renderers ------------------------------------------
def _render_runs(self, h: int, w: int) -> None:
runs = self.snapshot.get("runs", [])
lines: List[Tuple[str, str]] = [
("%-14s %-12s %-16s %-14s %-6s %-11s %s"
% ("TYPE", "DEVICE", "BIN", "SESSION", "PANE", "ELAPSED",
"TITLE"), "dim"),
]
last_type = None
for r in runs:
typ = r.get("type", "?")
if typ != last_type:
lines.append(("", "normal"))
last_type = typ
attr = "green" if not typ.startswith("other:") else "yellow"
lines.append((
"%-14s %-12s %-16s %-14s %-6s %-11s %s" % (
typ[:14], r.get("device", "?")[:12],
r.get("bin", NA)[:16], display_session(r)[:14],
str(r.get("pane", NA))[:6],
str(r.get("etime", NA))[:11],
r.get("title", "")[: w - 80]), attr))
if not runs:
lines.append(("(No agent runs found on probed devices.)",
"dim"))
lines.extend(self._note_lines(w))
self._body(h, w, "AGENT RUNS SORTED BY TYPE (%d)" % len(runs),
lines)
def _render_types(self, h: int, w: int) -> None:
by_type = self.snapshot.get("by_type", {})
lines: List[Tuple[str, str]] = []
total = sum(b.get("total", 0) for b in by_type.values())
lines.append(("Agent types: %d | total runs: %d"
% (len(by_type), total), "cyan"))
lines.append(("", "normal"))
for typ in sorted(by_type, key=type_sort_key):
bucket = by_type[typ]
attr = "green" if not typ.startswith("other:") else "yellow"
lines.append(("%-16s %d" % (typ, bucket.get("total", 0)),
attr))
for dev, n in sorted(bucket.get("devices", {}).items()):
lines.append((" %-14s %d" % (dev, n), "normal"))
lines.append(("", "normal"))
if not by_type:
lines.append(("(No agent types observed.)", "dim"))
lines.extend(self._note_lines(w))
self._body(h, w, "COUNTS BY AGENT TYPE", lines)
def _render_devices(self, h: int, w: int) -> None:
devices = self.snapshot.get("devices", [])
runs = self.snapshot.get("runs", [])
counts: Dict[str, int] = {}
for r in runs:
dev = r.get("device", "?")
counts[dev] = counts.get(dev, 0) + 1
unreach = set(self.snapshot.get("unreachable", []))
lines: List[Tuple[str, str]] = [
("%-24s %-15s %-7s %-7s %-5s %s"
% ("DEVICE", "IP", "OS", "PROBED", "RUNS", "DETAIL"), "dim"),
]
for d in devices:
name = d.get("name", "?")
probed = bool(d.get("local") or d.get("ssh"))
if name in unreach:
attr = "red"
elif not probed:
attr = "dim"
elif counts.get(name):
attr = "green"
else:
attr = "normal"
lines.append((
"%-24s %-15s %-7s %-7s %-5s %s" % (
name[:24] + (" *" if d.get("local") else ""),
d.get("ip", NA)[:15], str(d.get("os", NA))[:7],
"yes" if probed else "no",
counts.get(name, 0) if probed else NA,
str(d.get("detail", ""))[: w - 66]), attr))
lines.append(("", "normal"))
lines.append(("* = local (no SSH); unreachable shows n/a, never "
"blocks the rest.", "dim"))
lines.extend(self._note_lines(w))
self._body(h, w, "TAILNET DEVICES", lines)
def _render_help(self, h: int, w: int) -> None:
modal_w = min(64, w - 6)
modal_h = 13
top = (h - modal_h) // 2
left = (w - modal_w) // 2
for y in range(top, top + modal_h):
self.safe_addstr(y, left, " " * modal_w, self._attr("row_sel"))
self.safe_addstr(top, left, "+" + "-" * (modal_w - 2) + "+",
self._attr("cyan"))
for y in range(top + 1, top + modal_h - 1):
self.safe_addstr(y, left, "|", self._attr("cyan"))
self.safe_addstr(y, left + modal_w - 1, "|",
self._attr("cyan"))
self.safe_addstr(top + modal_h - 1, left,
"+" + "-" * (modal_w - 2) + "+",
self._attr("cyan"))
self.safe_addstr(top + 1, left + 3, "AGENT MANAGER HELP (read-only)",
self._attr("cyan"))
for i, hint in enumerate([
"1-3 / Tab: switch surfaces",
"j/k / Up/Down: scroll",
"r: refresh snapshot now",
"q / Esc: quit (Esc closes help first)",
"",
"One SSH call per device per refresh.",
"Missing data shows as 'n/a' — never a crash.",
]):
self.safe_addstr(top + 3 + i, left + 4, hint,
self._attr("normal"))
# -- input + main loop ------------------------------------------
def _handle_key(self, ch: int) -> bool:
if ch in (3, 4): # Ctrl+C / Ctrl+D
return False
if self.show_help:
if ch in (27, ord("q"), ord("Q"), ord("?")):
self.show_help = False
return True
if ch in (ord("q"), ord("Q")):
return False
if ch in (27, ord("?")):
self.show_help = True
return True
if ch in (ord("1"), ord("2"), ord("3")):
self.current_tab = ch - ord("1")
self.scroll = 0
return True
if ch == ord("\t"):
self.current_tab = (self.current_tab + 1) % len(self.tabs)
self.scroll = 0
return True
if ch in (ord("j"), curses.KEY_DOWN):
self.scroll += 1
return True
if ch in (ord("k"), curses.KEY_UP):
self.scroll = max(0, self.scroll - 1)
return True
if ch in (ord("r"), ord("R")):
self.refresh()
return True
return True
def run(self) -> None:
renderers = [self._render_runs, self._render_types,
self._render_devices]
while True:
h, w = self.stdscr.getmaxyx()
self.stdscr.erase()
self._render_header(w)
try:
renderers[self.current_tab](h, w)
except Exception as e:
self.safe_addstr(5, 4, "Render error (n/a): %s" % e,
self._attr("red"))
self._render_footer(h, w)
if self.show_help:
self._render_help(h, w)
self.stdscr.refresh()
try:
ch = self.stdscr.getch()
if ch != -1 and not self._handle_key(ch):
break
except KeyboardInterrupt:
break
if time.time() - self.last_refresh > AUTO_REFRESH_S:
self.refresh()
time.sleep(0.05)
def main(argv: Optional[List[str]] = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if "--once" in argv:
snap = gather_all()
if "--json" in argv:
print(json.dumps(snap, indent=2, default=str))
else:
print("== RUNS (%d) ==" % len(snap.get("runs", [])))
for r in snap.get("runs", []):
print("%-14s %-12s %-16s %s/%s %s" % (
r.get("type"), r.get("device"), r.get("bin"),
display_session(r), r.get("pane"), r.get("etime")))
print("== BY TYPE == ")
for typ, b in sorted(snap.get("by_type", {}).items()):
print("%s: %d %s" % (typ, b["total"], b["devices"]))
print("unreachable: %s" % snap.get("unreachable"))
if snap.get("note"):
print("note: %s" % snap["note"])
return 0
curses.wrapper(lambda stdscr: AgentManagerTUI(stdscr).run())
return 0
if __name__ == "__main__":
sys.exit(main())