diff --git a/cmd_chat/operator/__main__.py b/cmd_chat/operator/__main__.py index 1955369..d80afdc 100644 --- a/cmd_chat/operator/__main__.py +++ b/cmd_chat/operator/__main__.py @@ -136,6 +136,13 @@ def _build_parser() -> argparse.ArgumentParser: op.add_argument("--cost", type=float, default=5.0) op.add_argument("--session", default=None) + wb = sub.add_parser("web", help="serve a mobile chat UI in front of the daemon") + wb.add_argument("--bind", default="127.0.0.1", + help="listen address (default: 127.0.0.1 — loopback only; " + "the control socket grants room-send, so keep it local)") + wb.add_argument("--port", type=int, default=8790, help="listen port (default: 8790)") + wb.add_argument("--session", default=None) + sc = sub.add_parser("screen", help="print the relayed sandbox terminal buffer") sc.add_argument("--tail", type=int, default=None, help="last N bytes only") sc.add_argument("--ansi", action="store_true", help="keep ANSI codes (raw)") @@ -560,6 +567,11 @@ def _run_registry(args) -> int: return 0 +def _run_web(args) -> int: + from .web import serve + return serve(args.session, args.bind, args.port) + + def _run_simple(args, op: str) -> int: resp = _client_request(args, {"op": op}) print(json.dumps(resp)) @@ -599,6 +611,8 @@ def main(argv: list[str] | None = None) -> int: return _run_screen(args) if verb == "watch": return _run_watch(args) + if verb == "web": + return _run_web(args) if verb in ("roster", "status", "down"): return _run_simple(args, verb) return 2 diff --git a/cmd_chat/operator/web.py b/cmd_chat/operator/web.py new file mode 100644 index 0000000..98b9182 --- /dev/null +++ b/cmd_chat/operator/web.py @@ -0,0 +1,259 @@ +"""Mobile web chat front-end 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: + + 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) + +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). + +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. +""" + +from __future__ import annotations + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs + +from .cli_client import BridgeUnreachable, request +from .session import resolve + +# Long-poll window the browser holds open per /api/events call. The socket read +# timeout must outlast it (see request(read_timeout=...)). +_POLL_TIMEOUT = 25.0 + + +_PAGE = """ + + + + +hack-house · __SESSION__ + + + +
+ + __SESSION__ + +
+
+ + + + +""" + + +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 + + def _sock(self): + return resolve(self.session_name).sock_path + + def _send_json(self, obj: dict, code: int = 200) -> None: + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _bridge(self, req: dict, read_timeout: float = 35.0) -> dict: + try: + return request(self._sock(), req, read_timeout=read_timeout) + except BridgeUnreachable as e: + return {"ok": False, "error": str(e), "offline": True} + + # ── routes ─────────────────────────────────────────────────────────── + def do_GET(self) -> None: + url = urlparse(self.path) + if url.path == "/" or url.path == "/index.html": + page = _PAGE.replace("__SESSION__", resolve(self.session_name).name) + body = page.encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + if url.path == "/api/status": + self._send_json(self._bridge({"op": "status"}, 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( + {"op": "read", "since": since, "wait": True, "timeout": _POLL_TIMEOUT}, + read_timeout=_POLL_TIMEOUT + 5.0) + self._send_json(resp) + 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 + 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) + return + 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)) + + +def serve(session_name: str, bind: str, port: int) -> int: + """Run the web front-end until Ctrl-C. Returns a process exit code.""" + sess = resolve(session_name) + if not sess.sock_path.exists(): + print(f"no live bridge for session '{sess.name}' (run `up` first)") + return 2 + _Handler.session_name = session_name + 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)") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nweb UI stopped") + finally: + httpd.server_close() + return 0