Every Agents view marks each agent as working, alive, sleeping, suspending, unreachable or dead with an animated symbol; a Status menu hides states and switches the 3D faces, kept through a reload. The hub's container sees neither ~/.claude/sessions nor host processes, so the host timer kvb-orchestrator-sessions.timer runs client/sessions.py every 15 seconds while the page polls: it reads the session records without following symlinks, only regular files up to 64 KiB, keeps name, status and statusUpdatedAt within type and length bounds, checks the pid's process start, and writes agent-sessions.json atomically. The hub ignores a snapshot that is missing, malformed, older than five minutes or dated in the future. make orchestrator-install enables the timer and orchestrator-check reports it. 🎯 Fix: 90 % 💥 Break: 5 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 86 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
4.0 KiB
Python
Executable File
120 lines
4.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Args:
|
|
--sessions: Claude Code's sessions directory, one <pid>.json per session,
|
|
e.g. ~/.claude/sessions.
|
|
--output: the snapshot the Agent Orchestrator reads, e.g.
|
|
~/.local/state/kvb-orchestrator/agent-sessions.json.
|
|
|
|
Returns:
|
|
Runs on the host, because the hub's container sees neither these files
|
|
nor the host's processes: replaces --output with {"written": <epoch>,
|
|
"sessions": {<session id>: {"name", "status", "statusUpdatedAt",
|
|
"running"}}}, running being whether the record's pid still runs the
|
|
process it started. The exit code is 0.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import stat
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
PROC = Path("/proc")
|
|
KEPT = ("name", "status", "statusUpdatedAt")
|
|
RECORD_BYTES = 65536
|
|
VALUE_CHARS = 200
|
|
|
|
|
|
def read_record(path: Path) -> object:
|
|
"""Returns:
|
|
The parsed JSON of path, read without following a symlink and only
|
|
when it is a regular file of at most RECORD_BYTES.
|
|
|
|
Raises:
|
|
OSError: path is missing, a symlink, no regular file or too big.
|
|
ValueError, RecursionError: its content is no JSON or nested too deep.
|
|
"""
|
|
handle = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
with os.fdopen(handle, "rb") as stream:
|
|
info = os.fstat(stream.fileno())
|
|
if not stat.S_ISREG(info.st_mode) or info.st_size > RECORD_BYTES:
|
|
raise OSError(f"{path}: no regular file of at most {RECORD_BYTES} bytes")
|
|
return json.loads(stream.read(RECORD_BYTES + 1).decode("utf-8"))
|
|
|
|
|
|
def kept(record: dict) -> dict:
|
|
return {
|
|
key: record[key]
|
|
for key in KEPT
|
|
if isinstance(record.get(key), str | int | float) and not isinstance(record[key], bool) and len(str(record[key])) <= VALUE_CHARS
|
|
}
|
|
|
|
|
|
def read_sessions(folder: Path) -> dict[str, dict]:
|
|
"""Returns:
|
|
The session records under folder by session id; unreadable files and
|
|
records without a session id are left out.
|
|
"""
|
|
found = {}
|
|
for path in sorted(folder.glob("*.json")):
|
|
try:
|
|
data = read_record(path)
|
|
except (OSError, ValueError, RecursionError):
|
|
continue
|
|
if isinstance(data, dict) and isinstance(data.get("sessionId"), str):
|
|
found[data["sessionId"]] = data
|
|
return found
|
|
|
|
|
|
def running(record: dict, proc: Path) -> bool:
|
|
"""Returns:
|
|
Whether the record's pid still runs the process it started: the
|
|
start tick in /proc/<pid>/stat (field 22) equals procStart, so a
|
|
reused pid counts as gone.
|
|
"""
|
|
pid, start = record.get("pid"), record.get("procStart")
|
|
if isinstance(pid, bool) or not isinstance(pid, int) or not isinstance(start, str):
|
|
return False
|
|
try:
|
|
fields = (proc / str(pid) / "stat").read_text(encoding="utf-8").rpartition(")")[2].split()
|
|
except OSError:
|
|
return False
|
|
return len(fields) > 19 and fields[19] == start
|
|
|
|
|
|
def snapshot(folder: Path, proc: Path, now: float) -> dict:
|
|
return {
|
|
"written": now,
|
|
"sessions": {
|
|
sid: {**kept(record), "running": running(record, proc)}
|
|
for sid, record in read_sessions(folder).items()
|
|
},
|
|
}
|
|
|
|
|
|
def write(output: Path, data: dict) -> None:
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=output.parent, prefix=f".{output.name}.", delete=False) as handle:
|
|
try:
|
|
json.dump(data, handle)
|
|
except BaseException:
|
|
os.unlink(handle.name)
|
|
raise
|
|
os.replace(handle.name, output)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Snapshot Claude Code's sessions for the Agent Orchestrator.")
|
|
parser.add_argument("--sessions", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
write(args.output, snapshot(args.sessions, PROC, time.time()))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|