diff --git a/cmd_chat/operator/web.py b/cmd_chat/operator/web.py index 98b9182..353374d 100644 --- a/cmd_chat/operator/web.py +++ b/cmd_chat/operator/web.py @@ -1,22 +1,24 @@ -"""Mobile web chat front-end for the operator bridge (`operator web`). +"""Mobile hack-house console for the operator bridge (`operator web`). -Turns a running operator daemon into a phone-friendly chat page. The daemon -already exposes the room as a local API over its AF_UNIX control socket -(`read` with long-poll, `say`, `roster`, `status`); this module is a thin, -**stdlib-only** HTTP shim in front of that socket so a browser can drive it: +A phone-friendly browser mirror of a live hack-house room. The daemon already +exposes the room as a local API over its AF_UNIX control socket; this module is +a thin, **stdlib-only** HTTP shim in front of it so a browser gets the same +things the Rust TUI shows — the shared sandbox terminal, chat, roster — mobile +optimised: - GET / the single-page chat UI (self-contained HTML/JS) - GET /api/events?since=N long-poll: proxies {"op":"read","wait":true} - POST /api/say {text} proxies {"op":"say"} - GET /api/status proxies {"op":"status"} (roster + connection) + GET / the single-page console (self-contained HTML/JS) + GET /api/status proxies {"op":"status"} (connection/roster/driver) + GET /api/events?since=N long-poll: proxies {"op":"read","wait":true} + POST /api/say {text} proxies {"op":"say"} + GET /api/screen?ansi=1 proxies {"op":"screen"} — the relayed shared PTY + POST /api/keys {tokens} proxies {"op":"keys"} — drive the shared PTY -Why this exists: on Termux the tmux `read --wait` loop is read-only — replying -means a second shell typing `operator say …`, which is painful on a touch -keyboard. A browser page gives a real input box, scrollback and live updates -with zero extra dependencies (no server stack, no JS build — just http.server). +Why this exists: on Termux the tmux `read --wait` loop is read-only and shows +none of the sandbox. A browser page gives the terminal view + a real input box ++ live updates with zero extra dependencies (no server stack, no JS build). -Binds 127.0.0.1 by default: the control socket grants room-send rights, so the -listener stays loopback-only unless you deliberately pass a routable --bind. +Binds 127.0.0.1 by default: the control socket grants room-send/drive rights, +so the listener stays loopback-only unless you pass a routable --bind. """ from __future__ import annotations @@ -41,130 +43,264 @@ _PAGE = """ hack-house · __SESSION__
__SESSION__ + watching
-
- +
+ + +
+ +
+
no shared sandbox yet — a room member runs /sbx and /grants this operator to drive it here.
+
+
+ + + + + + + + +
+
+ + +
+
+
+
+ +
+
+ +
+ @@ -175,7 +311,6 @@ class _Handler(BaseHTTPRequestHandler): session_name = "default" # set on the class before serving protocol_version = "HTTP/1.1" - # ── plumbing ───────────────────────────────────────────────────────── def log_message(self, *a): # silence per-request stderr spam pass @@ -199,7 +334,7 @@ class _Handler(BaseHTTPRequestHandler): # ── routes ─────────────────────────────────────────────────────────── def do_GET(self) -> None: url = urlparse(self.path) - if url.path == "/" or url.path == "/index.html": + if url.path in ("/", "/index.html"): page = _PAGE.replace("__SESSION__", resolve(self.session_name).name) body = page.encode() self.send_response(200) @@ -211,32 +346,50 @@ class _Handler(BaseHTTPRequestHandler): if url.path == "/api/status": self._send_json(self._bridge({"op": "status"}, read_timeout=8)) return + if url.path == "/api/screen": + q = parse_qs(url.query) + ansi = (q.get("ansi") or ["0"])[0] not in ("0", "", "false") + tail = q.get("tail") + req = {"op": "screen", "ansi": ansi} + if tail: + req["tail"] = int(tail[0]) + self._send_json(self._bridge(req, read_timeout=8)) + return if url.path == "/api/events": q = parse_qs(url.query) since = int((q.get("since") or ["0"])[0]) - resp = self._bridge( + self._send_json(self._bridge( {"op": "read", "since": since, "wait": True, "timeout": _POLL_TIMEOUT}, - read_timeout=_POLL_TIMEOUT + 5.0) - self._send_json(resp) + read_timeout=_POLL_TIMEOUT + 5.0)) return self._send_json({"ok": False, "error": "not found"}, code=404) - def do_POST(self) -> None: - url = urlparse(self.path) - if url.path != "/api/say": - self._send_json({"ok": False, "error": "not found"}, code=404) - return + def _read_body(self) -> dict: length = int(self.headers.get("Content-Length") or 0) raw = self.rfile.read(length) if length else b"{}" try: - text = str(json.loads(raw.decode()).get("text", "")).strip() - except (ValueError, AttributeError): - self._send_json({"ok": False, "error": "bad json"}, code=400) + obj = json.loads(raw.decode()) + return obj if isinstance(obj, dict) else {} + except ValueError: + return {} + + def do_POST(self) -> None: + url = urlparse(self.path) + if url.path == "/api/say": + text = str(self._read_body().get("text", "")).strip() + if not text: + self._send_json({"ok": False, "error": "empty"}, code=400) + return + self._send_json(self._bridge({"op": "say", "text": text}, read_timeout=15)) return - if not text: - self._send_json({"ok": False, "error": "empty"}, code=400) + if url.path == "/api/keys": + tokens = self._read_body().get("tokens") + if not isinstance(tokens, list) or not tokens: + self._send_json({"ok": False, "error": "no tokens"}, code=400) + return + self._send_json(self._bridge({"op": "keys", "keys": tokens}, read_timeout=10)) return - self._send_json(self._bridge({"op": "say", "text": text}, read_timeout=15)) + self._send_json({"ok": False, "error": "not found"}, code=404) def serve(session_name: str, bind: str, port: int) -> int: @@ -249,11 +402,11 @@ def serve(session_name: str, bind: str, port: int) -> int: httpd = ThreadingHTTPServer((bind, port), _Handler) httpd.daemon_threads = True shown = bind if bind not in ("0.0.0.0", "::") else "127.0.0.1" - print(f"operator web UI for session '{sess.name}' → http://{shown}:{port} (Ctrl-C to stop)") + print(f"operator web console for session '{sess.name}' → http://{shown}:{port} (Ctrl-C to stop)") try: httpd.serve_forever() except KeyboardInterrupt: - print("\nweb UI stopped") + print("\nweb console stopped") finally: httpd.server_close() return 0