Answer the agents' questions on the page

Menus and permission prompts are read off a seat's screen and shown as
labelled buttons (plus Esc); an agent that stopped with a question gets a
reply box; queue items blocked on a human seat are decisions recorded with
rig queue resolve. Notifications now carry the question itself.

Claude-Session: https://claude.ai/code/session_01QhLkBGEip8wQdaZ54z4AqJ
This commit is contained in:
Asger Geel Weirsøe
2026-10-20 18:09:24 +02:00
parent 69cb255120
commit 150abd9afe
4 changed files with 252 additions and 42 deletions
+103 -13
View File
@@ -34,15 +34,67 @@ 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)
HUMAN_SEATS = re.compile(os.environ.get("RIG_POCKET_HUMAN_SEATS", r"^operator-human@"))
# the seat a decision is recorded as (rig derives the resolver from the seat environment)
HUMAN_ENV = {"OPENRIG_SESSION_NAME": os.environ.get("RIG_POCKET_HUMAN_SEAT", "operator-human@kernel")}
SEAT = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$")
KEYS = {"1", "2", "3", "4"}
QITEM = re.compile(r"^qitem-[A-Za-z0-9-]+$")
KEYS = {str(n) for n in range(1, 10)} | {"esc"}
CLOSED = {"done", "closed", "failed", "canceled", "cancelled"}
OPTION = re.compile(r"^\s*(❯\s*)?(\d)\.\s+(\S.*?)\s*$")
FRAME = re.compile(r"^[\s│┃|╌─━┄┈╭╮╰╯]+|[\s│┃|╌─━┄┈╭╮╰╯]+$")
BULLET = re.compile(r"^\s*●\s?")
INPUT_RULE = re.compile(r"^\s*─{8,}")
def rig(*args, timeout=20):
def clean(line):
return FRAME.sub("", line).strip()
def parse_ask(screen, idle):
"""What a seat is waiting for, read off its screen.
menu: a numbered choice with the cursor (❯) on it (permission prompts, multiple choice questions)
question: the agent stopped and its last message ends in a question
"""
lines = [l for l in screen.splitlines() if not l.startswith("[rig capture:")]
tail = lines[-30:]
opts = [(i, OPTION.match(l)) for i, l in enumerate(tail)]
opts = [(i, m) for i, m in opts if m]
if opts and any(m.group(1) for _, m in opts):
above = [clean(l) for l in tail[max(0, opts[0][0] - 8):opts[0][0]]]
above = [t for t in above if t and not t.startswith(("Tip:", "Esc to"))]
# the question line if there is one ("Do you want to proceed?"), else the nearest heading
title = next((t for t in reversed(above) if t.endswith("?")), above[-1] if above else "")
options = [{"key": m.group(2), "label": m.group(3)} for _, m in opts]
return {"kind": "menu", "title": title, "options": options}
if not idle:
return None
# the last agent message: from the last "●" line down to the input box rule
end = next((i for i in range(len(lines) - 1, -1, -1) if INPUT_RULE.match(lines[i])), len(lines))
end = next((i for i in range(end - 1, -1, -1) if INPUT_RULE.match(lines[i])), end)
start = next((i for i in range(end - 1, -1, -1) if BULLET.match(lines[i])), None)
if start is None:
return None
body = [lines[start]]
for l in lines[start + 1:end]:
if l and not l[0].isspace(): # status line (✻ …) or the input box: the message has ended
break
body.append(l)
text = " ".join(BULLET.sub("", l).strip() for l in body if l.strip())
text = re.sub(r"\s+", " ", text).strip()
if text.endswith("?") and not text.startswith(("Bash(", "Read(", "Edit(", "Write(")):
return {"kind": "question", "text": text[-600:]}
return None
def rig(*args, timeout=20, env=None):
try:
out = subprocess.run([RIG, *args], capture_output=True, text=True, timeout=timeout)
out = subprocess.run([RIG, *args], capture_output=True, text=True, timeout=timeout,
env={**os.environ, **env} if env else None)
return out.stdout
except (OSError, subprocess.TimeoutExpired):
return ""
@@ -87,22 +139,30 @@ def collect():
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 ""
activity = (n.get("agentActivity") or {}).get("state", "unknown")
# human seats are dashboards, not agents: nothing on them is a question for the person
human = bool(HUMAN_SEATS.search(name))
screen = rig("capture", name, "--lines", "40") if name and not human else ""
ask = parse_ask(screen, activity == "idle") if screen else None
seats.append({
"name": name,
"activity": (n.get("agentActivity") or {}).get("state", "unknown"),
"activity": activity,
"status": n.get("sessionStatus"),
"work": n.get("assignedWorkCount", 0),
"lastActivity": n.get("lastActivity"),
"waiting": bool(PROMPT.search(screen)),
"waiting": ask is not None,
"ask": ask,
})
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")})
dest = q.get("destinationSession") or ""
queue.append({"id": q.get("qitemId"), "state": q.get("state"), "dest": dest,
"source": q.get("sourceSession"), "summary": q.get("summary"), "updated": q.get("tsUpdated"),
# a human gate: blocked on a human seat until someone records a decision (rig queue resolve)
"gate": q.get("state") == "blocked" and bool(HUMAN_SEATS.search(q.get("blockedOn") or ""))})
repos = []
for p in repo_paths():
@@ -140,6 +200,7 @@ class Watcher:
self.waiting_polls = {}
self.notified_waiting = set()
self.queue_state = None
self.notified_gates = set()
def events(self, snap):
out = []
@@ -165,6 +226,11 @@ class Watcher:
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()}
for k, q in queue.items():
if q.get("gate") and k not in self.notified_gates:
out.append(("Beslutning til dig", q["summary"] or k, 4, "raising_hand"))
self.notified_gates = {k for k, q in queue.items() if q.get("gate")}
# 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"]:
@@ -172,12 +238,20 @@ class Watcher:
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"))
out.append((f"{n} venter på svar", ask_text(s.get("ask")), 4, "warning"))
if not s["waiting"]:
self.notified_waiting.discard(n)
return out
def ask_text(ask):
if not ask:
return "Åbn for at se skærmen og svare."
if ask["kind"] == "menu":
return (ask["title"] + "\n" + "\n".join(f"{o['key']}. {o['label']}" for o in ask["options"])).strip()
return ask["text"]
STATE = {"snap": None}
LOCK = threading.Lock()
@@ -202,6 +276,12 @@ def known_seats():
return {s["name"] for r in snap["rigs"] for s in r["seats"]}
def open_gates():
with LOCK:
snap = STATE["snap"] or {"queue": []}
return {q["id"] for q in snap["queue"] if q.get("gate")}
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"}
@@ -253,7 +333,7 @@ class Handler(BaseHTTPRequestHandler):
self.send(200, data, TYPES.get(f.suffix, "application/octet-stream"), cache)
def do_POST(self):
if self.path != "/api/send":
if self.path not in ("/api/send", "/api/resolve"):
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):
@@ -262,11 +342,21 @@ class Handler(BaseHTTPRequestHandler):
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")
text = (body.get("text") or "").strip()
if self.path == "/api/resolve":
qid = body.get("id", "")
if not QITEM.match(qid) or qid not in open_gates():
return self.send(404, '{"error":"unknown decision"}')
if not text or len(text) > 2000:
return self.send(400, '{"error":"empty decision"}')
out = rig("queue", "resolve", qid, "--decision", text, env=HUMAN_ENV)
return self.send(200, json.dumps({"ok": True, "result": out.strip()[:500]}))
seat, key = body.get("seat", ""), 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")
keys = "\x1b" if key == "esc" else key
out = rig("send", seat, keys, "--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: