Files
rig-pocket/server.py
T
Asger Geel Weirsøe 69cb255120 Rig Pocket: phone status page and ntfy notifier for OpenRig
Installable PWA showing rigs, seats (working, idle, waiting for an answer),
queue items and recent commits; seat screens with replies; push through ntfy.
Reads state through the rig CLI only, Python standard library, no build step,
styled entirely with the shared weirsoe-theme.

Claude-Session: https://claude.ai/code/session_01QhLkBGEip8wQdaZ54z4AqJ
2026-10-20 17:43:55 +02:00

284 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""rig-pocket: a phone-friendly status page and push notifier for OpenRig.
Reads state only through the documented `rig` CLI (`--json`), so it needs no
access to the daemon's internals. Standard library only.
"""
import glob
import json
import os
import re
import subprocess
import threading
import time
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
HERE = Path(__file__).resolve().parent
STATIC = HERE / "static"
RIG = os.environ.get("RIG_BIN", "rig")
BIND = os.environ.get("RIG_POCKET_BIND", "127.0.0.1")
PORT = int(os.environ.get("RIG_POCKET_PORT", "7440"))
POLL = int(os.environ.get("RIG_POCKET_POLL", "30"))
REPOS = os.environ.get("RIG_POCKET_REPOS", "") # space separated globs of git work trees
ORIGIN = os.environ.get("RIG_POCKET_ORIGIN", "") # e.g. https://rig.example.com, checked on POST
PUBLIC_URL = os.environ.get("RIG_POCKET_URL", "") # where a notification tap should go
THEME_CSS = os.environ.get("RIG_POCKET_THEME_CSS", "https://assets.weircon.dk/css/weirsoe-theme.css")
THEME_JS = os.environ.get("RIG_POCKET_THEME_JS", "https://assets.weircon.dk/js/weirsoe-theme.js")
NTFY_URL = os.environ.get("NTFY_URL", "")
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "")
NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "")
DONE_DEST = re.compile(os.environ.get("RIG_POCKET_DONE_DEST", r"^orch-lead@"))
HUMAN_DEST = re.compile(os.environ.get("RIG_POCKET_HUMAN_DEST", r"^operator-agent@kernel$"))
PROMPT = re.compile(r"Do you want to proceed\?|^\s*❯ 1\. ", re.M)
SEAT = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$")
KEYS = {"1", "2", "3", "4"}
CLOSED = {"done", "closed", "failed", "canceled", "cancelled"}
def rig(*args, timeout=20):
try:
out = subprocess.run([RIG, *args], capture_output=True, text=True, timeout=timeout)
return out.stdout
except (OSError, subprocess.TimeoutExpired):
return ""
def rig_json(*args):
try:
return json.loads(rig(*args) or "null")
except json.JSONDecodeError:
return None
def git(path, *args):
try:
return subprocess.run(["git", "-C", path, *args], capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return ""
def as_list(data):
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("items") or data.get("entries") or []
return []
def repo_paths():
paths = []
for pattern in REPOS.split():
for p in sorted(glob.glob(os.path.expanduser(pattern))):
if os.path.exists(os.path.join(p, ".git")):
paths.append(p)
return paths
def collect():
rigs = []
for r in as_list(rig_json("ps", "--json")):
if r.get("isArchived"):
continue
seats = []
for n in as_list(rig_json("ps", "--nodes", "--rig", r["name"], "--json")):
name = n.get("canonicalSessionName", "")
screen = rig("capture", name, "--lines", "25") if name else ""
seats.append({
"name": name,
"activity": (n.get("agentActivity") or {}).get("state", "unknown"),
"status": n.get("sessionStatus"),
"work": n.get("assignedWorkCount", 0),
"lastActivity": n.get("lastActivity"),
"waiting": bool(PROMPT.search(screen)),
})
rigs.append({"name": r["name"], "status": r.get("status"), "uptime": r.get("uptime"),
"attention": r.get("attentionCount", 0), "seats": seats})
queue = []
for q in as_list(rig_json("queue", "list", "-A", "-a", "--limit", "60", "--json")):
queue.append({"id": q.get("qitemId"), "state": q.get("state"), "dest": q.get("destinationSession"),
"source": q.get("sourceSession"), "summary": q.get("summary"), "updated": q.get("tsUpdated")})
repos = []
for p in repo_paths():
commits = []
for line in git(p, "log", "--branches", "-15", "--format=%H%x09%h%x09%cI%x09%D%x09%s").splitlines():
full, short, when, refs, subject = (line.split("\t") + [""] * 5)[:5]
commits.append({"hash": full, "short": short, "when": when, "refs": refs, "subject": subject})
repos.append({"name": os.path.basename(p), "commits": commits})
return {"updated": time.time(), "rigs": rigs, "queue": queue, "repos": repos}
def notify(title, body, priority=3, tags=""):
if not (NTFY_URL and NTFY_TOPIC):
return
headers = {"Title": title.encode("utf-8"), "Priority": str(priority)}
if tags:
headers["Tags"] = tags
if PUBLIC_URL:
headers["Click"] = PUBLIC_URL
if NTFY_TOKEN:
headers["Authorization"] = f"Bearer {NTFY_TOKEN}"
req = urllib.request.Request(f"{NTFY_URL.rstrip('/')}/{NTFY_TOPIC}", data=body.encode("utf-8"), headers=headers)
try:
urllib.request.urlopen(req, timeout=10).close()
except OSError:
pass
class Watcher:
"""Turns successive snapshots into notifications. The first snapshot only seeds."""
def __init__(self):
self.seen_commits = None
self.waiting_polls = {}
self.notified_waiting = set()
self.queue_state = None
def events(self, snap):
out = []
commits = {c["hash"]: (r["name"], c) for r in snap["repos"] for c in r["commits"]}
queue = {q["id"]: q for q in snap["queue"]}
if self.seen_commits is None:
self.seen_commits = set(commits)
self.queue_state = {k: q["state"] for k, q in queue.items()}
else:
new = [commits[h] for h in commits if h not in self.seen_commits]
self.seen_commits |= set(commits)
by_repo = {}
for repo, c in new:
by_repo.setdefault(repo, []).append(c["subject"])
for repo, subjects in by_repo.items():
out.append((f"{repo}: {len(subjects)} ny commit" + ("s" if len(subjects) > 1 else ""),
"\n".join(subjects), 2, "package"))
for k, q in queue.items():
before = self.queue_state.get(k)
if before is None and HUMAN_DEST.search(q["dest"] or "") and q["state"] not in CLOSED:
out.append(("Teamet spørger dig", q["summary"] or q["source"] or k, 4, "raising_hand"))
if before and before not in CLOSED and q["state"] in CLOSED and DONE_DEST.search(q["dest"] or ""):
out.append((f"Opgave {q['state']}: {q['dest']}", q["summary"] or k, 4, "white_check_mark"))
self.queue_state = {k: q["state"] for k, q in queue.items()}
# a prompt must survive two polls before it pings, so prompts answered quickly stay quiet
for r in snap["rigs"]:
for s in r["seats"]:
n = s["name"]
self.waiting_polls[n] = self.waiting_polls.get(n, 0) + 1 if s["waiting"] else 0
if self.waiting_polls[n] >= 2 and n not in self.notified_waiting:
self.notified_waiting.add(n)
out.append((f"{n} venter på svar", "Åbn for at se skærmen og svare.", 4, "warning"))
if not s["waiting"]:
self.notified_waiting.discard(n)
return out
STATE = {"snap": None}
LOCK = threading.Lock()
def poll_loop():
watcher = Watcher()
while True:
try:
snap = collect()
with LOCK:
STATE["snap"] = snap
for title, body, prio, tags in watcher.events(snap):
notify(title, body, prio, tags)
except Exception as e: # keep polling whatever one round did
print("poll failed:", e, flush=True)
time.sleep(POLL)
def known_seats():
with LOCK:
snap = STATE["snap"] or {"rigs": []}
return {s["name"] for r in snap["rigs"] for s in r["seats"]}
TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
".webmanifest": "application/manifest+json", ".svg": "image/svg+xml", ".png": "image/png",
".json": "application/json"}
def csp():
ext = " ".join(sorted({u.split("/", 3)[0] + "//" + u.split("/", 3)[2] for u in (THEME_CSS, THEME_JS) if u.startswith("http")}))
return (f"default-src 'self'; style-src 'self' {ext}; script-src 'self' {ext}; img-src 'self' data:; "
"connect-src 'self'; manifest-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'")
class Handler(BaseHTTPRequestHandler):
server_version = "rig-pocket"
def log_message(self, *args):
pass
def send(self, code, body, ctype="application/json", cache="no-store"):
data = body if isinstance(body, bytes) else body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", cache)
self.send_header("Content-Security-Policy", csp())
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.end_headers()
self.wfile.write(data)
def do_GET(self):
path = self.path.split("?", 1)[0]
if path == "/api/status":
with LOCK:
snap = STATE["snap"]
return self.send(200, json.dumps(snap or {"updated": 0, "rigs": [], "queue": [], "repos": []}))
if path.startswith("/api/capture/"):
seat = urllib.parse.unquote(path.rsplit("/", 1)[1])
if not SEAT.match(seat) or seat not in known_seats():
return self.send(404, '{"error":"unknown seat"}')
return self.send(200, json.dumps({"seat": seat, "screen": rig("capture", seat, "--lines", "60")}))
name = "index.html" if path in ("/", "") else path.lstrip("/")
f = (STATIC / name).resolve()
if STATIC not in f.parents or not f.is_file():
return self.send(404, "not found", "text/plain")
data = f.read_bytes()
if f.name == "index.html":
data = data.replace(b"{{THEME_CSS}}", THEME_CSS.encode()).replace(b"{{THEME_JS}}", THEME_JS.encode())
cache = "no-cache" if f.suffix in (".html", ".webmanifest") or f.name == "sw.js" else "max-age=300"
self.send(200, data, TYPES.get(f.suffix, "application/octet-stream"), cache)
def do_POST(self):
if self.path != "/api/send":
return self.send(404, '{"error":"not found"}')
# a custom header forces a CORS preflight, so other sites cannot post here
if self.headers.get("X-Rig-Pocket") != "1" or (ORIGIN and self.headers.get("Origin") != ORIGIN):
return self.send(403, '{"error":"forbidden"}')
try:
body = json.loads(self.rfile.read(min(int(self.headers.get("Content-Length", 0)), 8192)))
except (ValueError, json.JSONDecodeError):
return self.send(400, '{"error":"bad json"}')
seat, text, key = body.get("seat", ""), (body.get("text") or "").strip(), body.get("key")
if not SEAT.match(seat) or seat not in known_seats():
return self.send(404, '{"error":"unknown seat"}')
if key in KEYS:
out = rig("send", seat, key, "--dangerously-interact", "--reason", "rig-pocket: answered from phone")
elif text and len(text) <= 2000:
out = rig("send", seat, f"From the person (via rig-pocket on their phone): {text}")
else:
return self.send(400, '{"error":"nothing to send"}')
self.send(200, json.dumps({"ok": True, "result": out.strip()[:500]}))
def main():
threading.Thread(target=poll_loop, daemon=True).start()
ThreadingHTTPServer((BIND, PORT), Handler).serve_forever()
if __name__ == "__main__":
main()