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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user