From 45741f115109d6d77bbfb614d70eae8a23abd073 Mon Sep 17 00:00:00 2001 From: operator-main Date: Sun, 4 Oct 2026 04:31:39 +0000 Subject: [PATCH] =?UTF-8?q?bin/box-chat.py=20+=20box-chat-cdp.py=20?= =?UTF-8?q?=E2=80=94=20read-only=20bl=20helper=20for=20Box=20thread=20over?= =?UTF-8?q?sight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session: sidechat/box-chat --- bin/box-chat-cdp.py | 305 +++++++++++++++++++++++++++++++++++ bin/box-chat.py | 380 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 685 insertions(+) create mode 100755 bin/box-chat-cdp.py create mode 100755 bin/box-chat.py diff --git a/bin/box-chat-cdp.py b/bin/box-chat-cdp.py new file mode 100755 index 0000000..16cdd18 --- /dev/null +++ b/bin/box-chat-cdp.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""box-chat-cdp.py — READ-ONLY CDP scraper for box-chat.py. + +Runs INSIDE the agent's netns (via netvm-exec.sh), where the agent's +headless Chromium CDP port is reachable on 127.0.0.1. Performs only +Runtime.evaluate reads plus benign navigation clicks (panel open, thread +switch, restore-to-main). Never sends messages, never touches the +composer, never clicks send. + +Usage: + box-chat-cdp.py threads + box-chat-cdp.py messages + +Prints exactly one JSON object to stdout. Exit 0 on success, 1 on +failure (stdout still carries {"ok": false, ...}). +""" + +import importlib.util +import json +import sys +import time +import urllib.request + + +def load_accounts(): + path = "/home/super/Projects/NetVM/bin/netvm-registry.py" + spec = importlib.util.spec_from_file_location("netvm_registry", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + accounts = {} + for node, rec in mod.load().items(): + accounts[node] = (node, "http://127.0.0.1:%d/json/list" % rec["cdp_port"]) + return accounts + + +def connect(agent): + accounts = load_accounts() + if agent not in accounts: + raise RuntimeError("unknown agent in registry: %s" % agent) + _node, cdp_url = accounts[agent] + with urllib.request.urlopen(cdp_url, timeout=10) as r: + targets = json.load(r) + pages = [t for t in targets if t.get("type") == "page"] + if not pages: + raise RuntimeError("no page target on CDP") + import websocket + ws = websocket.create_connection( + pages[0]["webSocketDebuggerUrl"], timeout=60) + return ws + + +def ev(ws, expr, await_p=False): + ws.send(json.dumps({ + "id": 1, "method": "Runtime.evaluate", + "params": {"expression": expr, "returnByValue": True, + "awaitPromise": await_p}, + })) + resp = json.loads(ws.recv()) + res = resp.get("result", {}) + if res.get("subtype") == "error": + raise RuntimeError("JS error: %s" % str(res.get("description"))[:200]) + return res.get("result", {}).get("value") + + +TITLE_OF = """const titleOf = r => { + const s = r.querySelector('span[title]'); + return s ? s.getAttribute('title').trim() + : ((r.innerText||'').split('\\n')[0]||'').trim(); +};""" + +ENSURE = """(async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + %s + if (window.location.pathname === '/thread/new') { + window.location.href = '/'; await sleep(4000); + } + const nav = document.querySelector('[data-testid="hatch-nav-chat"]'); + if (!nav) return 'NO_CHAT_NAV'; + if (nav.getAttribute('aria-current') !== 'page') { nav.click(); await sleep(3000); } + const panelOpen = () => !!document.querySelector('[data-testid="hatch-chat-compose"]'); + if (!panelOpen()) { + const sw = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]'); + if (!sw) return 'NO_SWITCHER'; + sw.click(); await sleep(2500); + if (!panelOpen()) return 'PANEL_CLOSED'; + } + return 'OK'; +})()""" % TITLE_OF + + +def op_threads(ws): + st = ev(ws, ENSURE, await_p=True) + if st != "OK": + raise RuntimeError("could not reach chat panel: %s" % st) + js = """(async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + %s + const snap = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')] + .map(r => { + const spans = [...r.querySelectorAll('span')]; + return {title: titleOf(r), + rel: spans.length ? (spans[spans.length-1].innerText||'').trim() : ''}; + }); + const ensurePanel = async () => { + if (document.querySelector('[data-testid="hatch-chat-compose"]')) return true; + for (let k = 0; k < 3; k++) { + const sw = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]'); + if (!sw) return false; + sw.click(); await sleep(3000); + if (document.querySelector('[data-testid="hatch-chat-compose"]')) return true; + } + return false; + }; + const out = []; + for (const s of snap) { + const isMain = /^main chat$/i.test(s.title); + if (isMain) { out.push({id: 'main', kind: 'main', title: s.title, rel: s.rel}); continue; } + const panelOk = await ensurePanel(); + if (!panelOk) { out.push({id: null, kind: 'sidechat', title: s.title, rel: s.rel, error: 'PANEL_CLOSED'}); continue; } + const row = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')] + .find(r => titleOf(r) === s.title); + if (!row) { out.push({id: null, kind: 'sidechat', title: s.title, rel: s.rel, error: 'ROW_GONE'}); continue; } + row.click(); + let id = null; + for (let t = 0; t < 20; t++) { + await sleep(500); + const m = window.location.href.match(/\\/thread\\/([0-9a-fA-F-]{36})/); + if (m) { id = m[1]; break; } + } + if (!id) { + const row2 = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')] + .find(r => titleOf(r) === s.title); + if (row2) { + row2.click(); + for (let t = 0; t < 12; t++) { + await sleep(500); + const m = window.location.href.match(/\\/thread\\/([0-9a-fA-F-]{36})/); + if (m) { id = m[1]; break; } + } + } + } + out.push({id, kind: 'sidechat', title: s.title, rel: s.rel}); + } + return {threads: out}; + })()""" % TITLE_OF + return ev(ws, js, await_p=True) + + +def op_messages(ws, thread_id): + st = ev(ws, ENSURE, await_p=True) + if st != "OK": + raise RuntimeError("could not reach chat panel: %s" % st) + js = """(async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + %s + const THREAD = %s; + const ensurePanel = async () => { + if (document.querySelector('[data-testid="hatch-chat-compose"]')) return true; + for (let k = 0; k < 3; k++) { + const sw = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]'); + if (!sw) return false; + sw.click(); await sleep(3000); + if (document.querySelector('[data-testid="hatch-chat-compose"]')) return true; + } + return false; + }; + const uuidOf = () => { + const m = window.location.href.match(/\\/thread\\/([0-9a-fA-F-]{36})/); + return m ? m[1].toLowerCase() : null; + }; + let landed = false; + if (THREAD === 'main') { + await ensurePanel(); + const row = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')] + .find(r => /^main chat$/i.test(titleOf(r))); + if (row) { row.click(); await sleep(2500); } + landed = /muse\\.ai\\/?$/.test(window.location.href) && !uuidOf(); + } else { + // already there? + if (uuidOf() === THREAD.toLowerCase()) landed = true; + // click rows until the URL carries our thread uuid (SPA navigation, + // keeps the JS context alive unlike location.href assignment) + for (let i = 0; i < 12 && !landed; i++) { + await ensurePanel(); + const rows = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')]; + if (!rows.length) break; + const row = rows[i %% rows.length]; + row.click(); + for (let t = 0; t < 14; t++) { + await sleep(500); + if (uuidOf() === THREAD.toLowerCase()) { landed = true; break; } + if (uuidOf()) break; // navigated somewhere else; try next row + } + } + if (!landed) { + // fallback: SPA history navigation (no full page load) + window.history.pushState({}, '', '/thread/' + THREAD); + window.dispatchEvent(new PopStateEvent('popstate')); + await sleep(5000); + landed = uuidOf() === THREAD.toLowerCase(); + } + } + if (!landed) return {error: 'THREAD_NOT_FOUND'}; + await sleep(2500); + const sc = document.getElementById('hatch-chat-scroll'); + if (sc) { + for (let i = 0; i < 3; i++) { sc.scrollTop = 0; await sleep(1500); } + sc.scrollTop = sc.scrollHeight; await sleep(800); + } + const els = [...document.querySelectorAll('[data-message-id]')]; + const messages = els.map(m => { + const id = m.getAttribute('data-message-id'); + const ps = [...m.querySelectorAll('p')].map(p => (p.innerText||'').trim()).filter(Boolean); + let text = ps.join('\\n'); + if (!text) text = (m.innerText||'').replace(/^(Assistant message:|User message:)\\s*/, '').trim(); + const t = m.querySelector('time'); + return {id, + author: id.indexOf('assistant-msg') === 0 ? 'assistant' : 'user', + text, + ts: t ? (t.getAttribute('datetime') || t.innerText || null) : null}; + }); + return {url: window.location.href, messages}; + })()""" % (TITLE_OF, json.dumps(thread_id)) + data = ev(ws, js, await_p=True) + if not isinstance(data, dict) or "messages" not in data: + if isinstance(data, dict) and data.get("error") == "THREAD_NOT_FOUND": + raise RuntimeError("THREAD_NOT_FOUND: no such thread for this agent") + raise RuntimeError("unexpected messages payload") + return data + + +def restore_main(ws): + try: + ev(ws, """(() => { + %s + const row = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')] + .find(r => /^main chat$/i.test(titleOf(r))); + if (row) row.click(); + return 'ok'; + })()""" % TITLE_OF) + except Exception: + pass + + +def main(argv): + if len(argv) < 3: + print(json.dumps({"ok": False, "code": "BAD_ARGS", + "error": "usage: box-chat-cdp.py threads|messages [thread-id]"})) + return 1 + agent, op = argv[1], argv[2] + ws = None + try: + ws = connect(agent) + # CDP evaluate can rarely resolve null when the page is mid-navigation; + # one retry covers the flake (observed 2026-10-04). + data, last_err = None, None + for attempt in range(2): + try: + if op == "threads": + data = op_threads(ws) + ok = isinstance(data, dict) and isinstance( + data.get("threads"), list) + elif op == "messages": + if len(argv) < 4: + raise RuntimeError("messages requires thread-id") + data = op_messages(ws, argv[3]) + ok = isinstance(data, dict) and isinstance( + data.get("messages"), list) + else: + raise RuntimeError("unknown op: %s" % op) + if ok: + break + last_err = "empty CDP result" + data = None + except RuntimeError as e: + last_err = str(e) + data = None + time.sleep(3) + if data is None: + raise RuntimeError(last_err or "CDP returned no usable data") + if op == "threads": + print(json.dumps({"ok": True, "agent": agent, + "threads": data["threads"]})) + else: + print(json.dumps({"ok": True, "agent": agent, "url": data["url"], + "messages": data["messages"]})) + return 0 + except Exception as e: + print(json.dumps({"ok": False, "code": "CDP_ERROR", + "error": str(e)[:300]})) + return 1 + finally: + if ws is not None: + try: + restore_main(ws) + except Exception: + pass + try: + ws.close() + except Exception: + pass + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/bin/box-chat.py b/bin/box-chat.py new file mode 100755 index 0000000..715ba2b --- /dev/null +++ b/bin/box-chat.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +"""box-chat.py — allowlisted bl helper for Box thread oversight (READ-ONLY). + +The board server (VM) never scrapes browsers directly. All thread reads go +through this helper, invoked as: + + /home/super/Projects/NetVM/bin/box-chat.py thread-list [--kind K] [--limit N] + /home/super/Projects/NetVM/bin/box-chat.py thread-messages [--limit N] [--before MSGID] + +Security properties (mirrors box-ctl.py): +- Fixed verb set; every argument validated before acting. +- must be a known node (muse, pip, 646, opm); anything else exits + before any netns/SSH/CDP work. +- must match ^[a-zA-Z0-9-]{1,64}$ or be the literal "main". +- READ-ONLY by construction: the CDP driver (box-chat-cdp.py) only runs + Runtime.evaluate reads plus benign navigation clicks. No sends, no + composer interaction, no shell=True anywhere. All subprocess calls use + argv lists. +- Every invocation audit-logged to box-chat.jsonl with caller identity. + +Output: JSON to stdout ({"ok": true, ...} or {"ok": false, ...}), +exit 0 on success, nonzero on failure. +""" + +import json +import os +import re +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +NETVM_ROOT = Path("/home/super/Projects/NetVM") +BIN = NETVM_ROOT / "bin" +CDP_DRIVER = BIN / "box-chat-cdp.py" +NETVM_EXEC = BIN / "netvm-exec.sh" +CHAT_LOG = NETVM_ROOT / "box-chat.jsonl" +DM_LOG = NETVM_ROOT / "dm-log.jsonl" + +VALID_AGENTS = {"muse", "pip", "646", "opm"} +VALID_KINDS = {"main", "sidechat", "dm", "all"} +AGENT_RE = re.compile(r"^[a-z0-9-]{1,64}$") +THREAD_RE = re.compile(r"^[a-zA-Z0-9-]{1,64}$") +MSGID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$") + +REL_MONTHS = {m: i + 1 for i, m in enumerate( + ["jan", "feb", "mar", "apr", "may", "jun", + "jul", "aug", "sep", "oct", "nov", "dec"])} + + +def utcnow(): + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def out(ok, **kw): + payload = {"ok": ok} + payload.update(kw) + print(json.dumps(payload)) + + +def fail(code, error, detail=None, exit_code=1): + payload = {"ok": False, "code": code, "error": error} + if detail is not None: + payload["detail"] = detail + print(json.dumps(payload)) + sys.exit(exit_code) + + +def audit(action, agent=None, name=None, kind=None): + """Append invocation record to the bl-side log.""" + try: + entry = { + "ts": utcnow(), + "action": action, + "agent": agent, + "name": name, + "kind": kind, + "caller": os.environ.get("BOX_CALLER", "unknown"), + } + with open(CHAT_LOG, "a") as f: + f.write(json.dumps(entry) + "\n") + except Exception: + pass # audit failure must not break the action + + +def check_agent(agent): + if not agent or agent not in VALID_AGENTS: + fail("BAD_AGENT", "agent must be one of %s" % sorted(VALID_AGENTS), + {"field": "agent", "value": agent}) + return agent + + +def check_thread_id(tid): + if tid != "main" and not (tid and THREAD_RE.match(tid)): + fail("BAD_THREAD", "thread-id must be 'main' or match ^[a-zA-Z0-9-]{1,64}$", + {"field": "thread-id", "value": tid}) + return tid + + +def check_limit(val, default): + if val is None: + return default + try: + n = int(val) + except (TypeError, ValueError): + fail("BAD_LIMIT", "limit must be an integer 1-200", {"value": val}) + if not 1 <= n <= 200: + fail("BAD_LIMIT", "limit must be an integer 1-200", {"value": val}) + return n + + +def run_cdp(agent, op, args, timeout): + """Run the CDP driver inside the agent's netns. argv only, no shell.""" + cmd = [str(NETVM_EXEC), agent, "--", sys.executable, + str(CDP_DRIVER), agent, op] + args + try: + r = subprocess.run(cmd, capture_output=True, text=True, + timeout=timeout) + except subprocess.TimeoutExpired: + fail("CDP_TIMEOUT", "CDP driver timed out in netns", + {"agent": agent, "op": op}) + if r.returncode != 0 and not r.stdout.strip(): + fail("CDP_ERROR", "CDP driver failed", + {"stderr": (r.stderr or "")[-300:]}) + try: + data = json.loads(r.stdout.strip()) + except Exception: + fail("CDP_ERROR", "CDP driver returned non-JSON", + {"stdout": r.stdout[-300:], "stderr": (r.stderr or "")[-300:]}) + if not data.get("ok"): + code = data.get("code", "CDP_ERROR") + if "THREAD_NOT_FOUND" in str(data.get("error", "")): + code = "THREAD_NOT_FOUND" + fail(code, data.get("error", "cdp failed")) + return data + + +def parse_rel_time(rel): + """Panel relative times ('just now', '4m', '2h', '3d', 'Oct 3') -> + (approx ISO8601, True). Returns (None, False) when unparseable.""" + now = datetime.now(timezone.utc) + rel = (rel or "").strip().lower() + if rel in ("just now", "now"): + return now.strftime("%Y-%m-%dT%H:%M:%SZ"), True + m = re.match(r"^(\d+)\s*m(in(ute)?s?)?$", rel) + if m: + return (now - timedelta(minutes=int(m.group(1)))).strftime("%Y-%m-%dT%H:%M:%SZ"), True + m = re.match(r"^(\d+)\s*h((ou)?rs?)?$", rel) + if m: + return (now - timedelta(hours=int(m.group(1)))).strftime("%Y-%m-%dT%H:%M:%SZ"), True + m = re.match(r"^(\d+)\s*d(ays?)?$", rel) + if m: + return (now - timedelta(days=int(m.group(1)))).strftime("%Y-%m-%dT%H:%M:%SZ"), True + m = re.match(r"^([a-z]{3})\s+(\d{1,2})$", rel) + if m and m.group(1) in REL_MONTHS: + dt = now.replace(month=REL_MONTHS[m.group(1)], day=int(m.group(2)), + hour=12, minute=0, second=0, microsecond=0) + if dt > now: + dt = dt.replace(year=dt.year - 1) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ"), True + return None, False + + +def dm_conversations(agent): + """DM conversations involving , synthesized from dm-log.jsonl. + + Real timestamps and counts; bodies are not stored in the log (by design) + — message text for these threads is the wire tag, and full bodies live + in the target chat's messages. + """ + convos = {} + try: + with open(DM_LOG) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + except Exception: + continue + if e.get("type") not in ("sent", "send_done"): + continue + frm, to = e.get("agent"), e.get("to") + if not frm or not to: + continue + if agent not in (frm, to): + continue + key = tuple(sorted([frm, to])) + c = convos.setdefault(key, {"count": 0, "last_ts": "", + "entries": []}) + c["count"] += 1 + if e.get("ts", "") > c["last_ts"]: + c["last_ts"] = e["ts"] + c["entries"].append(e) + except FileNotFoundError: + return [] + out = [] + for (a, b), c in sorted(convos.items()): + out.append({ + "id": "dm-%s-%s" % (a, b), + "kind": "dm", + "title": "dm:%s:%s" % (a, b), + "participants": [a, b], + "last_message_at": c["last_ts"] or None, + "last_message_approx": False, + "message_count": c["count"], + }) + return out + + +def dm_thread_messages(agent, thread_id): + """Messages for a dm-- thread, from dm-log.jsonl (complete log).""" + parts = thread_id[3:].split("-") + if len(parts) != 2: + fail("THREAD_NOT_FOUND", "no such DM thread: %s" % thread_id) + a, b = parts + if agent not in (a, b): + fail("THREAD_NOT_FOUND", "no such DM thread: %s" % thread_id) + msgs = [] + try: + with open(DM_LOG) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + except Exception: + continue + if e.get("type") not in ("sent", "send_done"): + continue + frm, to = e.get("agent"), e.get("to") + if not frm or not to: + continue + if tuple(sorted([frm, to])) != (a, b): + continue + sender = e.get("agent") + msgs.append({ + "id": str(e.get("id", "")), + "from": {"role": "agent", "name": sender}, + "text": "[from:%s] [id:%s] -> %s" % ( + sender, e.get("id"), e.get("target", "?")), + "ts": e.get("ts"), + "target": e.get("target"), + "verified": e.get("verified"), + }) + except FileNotFoundError: + pass + msgs.sort(key=lambda m: m.get("ts") or "") + # de-dupe send_done/sent pairs on DM id, keep the richer record + seen = {} + for m in msgs: + prev = seen.get(m["id"]) + if prev is None or (m.get("verified") and not prev.get("verified")): + seen[m["id"]] = m + msgs = sorted(seen.values(), key=lambda m: m.get("ts") or "") + return msgs + + +def act_thread_list(agent, kind, limit): + threads = [] + if kind in ("main", "sidechat", "all"): + data = run_cdp(agent, "threads", [], timeout=240) + for t in data.get("threads", [])[:limit]: + if kind != "all" and t.get("kind") != kind: + continue + ts, approx = parse_rel_time(t.get("rel", "")) + threads.append({ + "id": t.get("id"), + "kind": t.get("kind"), + "title": t.get("title"), + "participants": [agent, "human"], + "last_message_at": ts, + "last_message_approx": approx, + "message_count": None, # list is a panel scrape; counts need a thread open + }) + if kind in ("dm", "all"): + threads.extend(dm_conversations(agent)) + audit("thread-list", agent=agent, kind=kind) + out(True, agent=agent, kind=kind, threads=threads, + fetched_at=utcnow()) + + +def act_thread_messages(agent, thread_id, limit, before): + if thread_id.startswith("dm-"): + msgs = dm_thread_messages(agent, thread_id) + thread = {"id": thread_id, "kind": "dm", + "title": "dm:%s" % thread_id[3:].replace("-", ":"), + "participants": thread_id[3:].split("-")} + else: + data = run_cdp(agent, "messages", [thread_id], timeout=150) + raw = data.get("messages", []) + thread = {"id": thread_id, + "kind": "main" if thread_id == "main" else "sidechat", + "participants": [agent, "human"]} + msgs = [{ + "id": m.get("id"), + "from": ({"role": "agent", "name": agent} + if m.get("author") == "assistant" + else {"role": "human", "name": "human"}), + "text": m.get("text", ""), + "ts": m.get("ts"), + } for m in raw] + if before: + if not MSGID_RE.match(before): + fail("BAD_CURSOR", "before must match ^[a-zA-Z0-9-]{1,128}$", + {"value": before}) + idx = next((i for i, m in enumerate(msgs) if m["id"] == before), None) + if idx is None: + fail("BAD_CURSOR", "no message with that id in loaded window", + {"value": before}) + msgs = msgs[:idx] + older = len(msgs) + if len(msgs) > limit: + msgs = msgs[-limit:] + next_before = msgs[0]["id"] if older > len(msgs) and msgs else None + audit("thread-messages", agent=agent, name=thread_id) + out(True, agent=agent, thread=thread, messages=msgs, + next_before=next_before, loaded_count=older, fetched_at=utcnow()) + + +USAGE = """usage: box-chat.py [args] + + thread-list [--kind main|sidechat|dm|all] [--limit N] + thread-messages [--limit N] [--before MSGID] + +read-only. is one of: muse, pip, 646, opm.""" + + +def parse_flags(rest, names): + """Parse [--flag value] pairs; returns (positionals, {flag: value}).""" + pos, flags = [], {} + i = 0 + while i < len(rest): + tok = rest[i] + if tok.startswith("--") and tok[2:] in names: + if i + 1 >= len(rest): + fail("BAD_ARGS", "flag %s needs a value" % tok) + flags[tok[2:]] = rest[i + 1] + i += 2 + elif tok.startswith("--"): + fail("BAD_ARGS", "unknown flag: %s" % tok) + else: + pos.append(tok) + i += 1 + return pos, flags + + +def main(argv): + if len(argv) < 2: + print(USAGE, file=sys.stderr) + sys.exit(2) + action = argv[1] + + if action == "thread-list": + pos, flags = parse_flags(argv[2:], {"kind", "limit"}) + if len(pos) != 1: + fail("BAD_ARGS", "usage: thread-list [--kind K] [--limit N]") + agent = check_agent(pos[0]) + kind = flags.get("kind", "all") + if kind not in VALID_KINDS: + fail("BAD_ARGS", "kind must be one of %s" % sorted(VALID_KINDS)) + act_thread_list(agent, kind, check_limit(flags.get("limit"), 50)) + elif action == "thread-messages": + pos, flags = parse_flags(argv[2:], {"limit", "before"}) + if len(pos) != 2: + fail("BAD_ARGS", + "usage: thread-messages [--limit N] [--before MSGID]") + agent = check_agent(pos[0]) + tid = check_thread_id(pos[1]) + act_thread_messages(agent, tid, check_limit(flags.get("limit"), 50), + flags.get("before")) + else: + print(USAGE, file=sys.stderr) + fail("BAD_ARGS", "unknown action: %s" % action) + + +if __name__ == "__main__": + main(sys.argv)