feat(operator): mobile web chat UI in front of the daemon
Add `operator web` — a stdlib-only http.server shim over the control socket so a phone browser can drive a room: message bubbles, live long-poll updates, roster, and a real text box (GET /, /api/events, /api/status, POST /api/say). Solves the Termux input gap where the tmux `read --wait` loop is read-only. Binds 127.0.0.1 by default since the socket grants room-send. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
op.add_argument("--cost", type=float, default=5.0)
|
op.add_argument("--cost", type=float, default=5.0)
|
||||||
op.add_argument("--session", default=None)
|
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 = 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("--tail", type=int, default=None, help="last N bytes only")
|
||||||
sc.add_argument("--ansi", action="store_true", help="keep ANSI codes (raw)")
|
sc.add_argument("--ansi", action="store_true", help="keep ANSI codes (raw)")
|
||||||
@@ -560,6 +567,11 @@ def _run_registry(args) -> int:
|
|||||||
return 0
|
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:
|
def _run_simple(args, op: str) -> int:
|
||||||
resp = _client_request(args, {"op": op})
|
resp = _client_request(args, {"op": op})
|
||||||
print(json.dumps(resp))
|
print(json.dumps(resp))
|
||||||
@@ -599,6 +611,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return _run_screen(args)
|
return _run_screen(args)
|
||||||
if verb == "watch":
|
if verb == "watch":
|
||||||
return _run_watch(args)
|
return _run_watch(args)
|
||||||
|
if verb == "web":
|
||||||
|
return _run_web(args)
|
||||||
if verb in ("roster", "status", "down"):
|
if verb in ("roster", "status", "down"):
|
||||||
return _run_simple(args, verb)
|
return _run_simple(args, verb)
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
@@ -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 = """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
|
<title>hack-house · __SESSION__</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
body {
|
||||||
|
font: 15px/1.4 -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #0d1117; color: #e6edf3;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 10px 14px; background: #161b22; border-bottom: 1px solid #30363d;
|
||||||
|
display: flex; align-items: center; gap: 10px; flex: none;
|
||||||
|
}
|
||||||
|
header .dot { width: 10px; height: 10px; border-radius: 50%; background: #f85149; flex: none; }
|
||||||
|
header .dot.on { background: #3fb950; }
|
||||||
|
header .name { font-weight: 600; }
|
||||||
|
header .roster { color: #8b949e; font-size: 13px; margin-left: auto;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.msg { max-width: 82%; padding: 8px 11px; border-radius: 14px; word-wrap: break-word; white-space: pre-wrap; }
|
||||||
|
.msg .who { font-size: 11px; color: #8b949e; margin-bottom: 2px; }
|
||||||
|
.msg.them { background: #21262d; align-self: flex-start; border-bottom-left-radius: 4px; }
|
||||||
|
.msg.me { background: #1f6feb; align-self: flex-end; border-bottom-right-radius: 4px; }
|
||||||
|
.msg.addr { box-shadow: 0 0 0 2px #d29922 inset; }
|
||||||
|
.sys { align-self: center; color: #8b949e; font-size: 12px; font-style: italic; }
|
||||||
|
footer { flex: none; display: flex; gap: 8px; padding: 10px; background: #161b22; border-top: 1px solid #30363d; }
|
||||||
|
#box { flex: 1; resize: none; background: #0d1117; color: #e6edf3; border: 1px solid #30363d;
|
||||||
|
border-radius: 10px; padding: 10px; font: inherit; max-height: 120px; }
|
||||||
|
#send { flex: none; background: #238636; color: #fff; border: 0; border-radius: 10px;
|
||||||
|
padding: 0 18px; font: inherit; font-weight: 600; }
|
||||||
|
#send:disabled { opacity: .5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<span class="dot" id="dot"></span>
|
||||||
|
<span class="name">__SESSION__</span>
|
||||||
|
<span class="roster" id="roster">—</span>
|
||||||
|
</header>
|
||||||
|
<div id="log"></div>
|
||||||
|
<footer>
|
||||||
|
<textarea id="box" rows="1" placeholder="Message the room…" autocomplete="off"></textarea>
|
||||||
|
<button id="send">Send</button>
|
||||||
|
</footer>
|
||||||
|
<script>
|
||||||
|
const ME = "__SESSION__";
|
||||||
|
const log = document.getElementById("log");
|
||||||
|
const box = document.getElementById("box");
|
||||||
|
const send = document.getElementById("send");
|
||||||
|
const dot = document.getElementById("dot");
|
||||||
|
const rosterEl = document.getElementById("roster");
|
||||||
|
let since = 0;
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
function atBottom() { return log.scrollHeight - log.scrollTop - log.clientHeight < 60; }
|
||||||
|
function scroll() { log.scrollTop = log.scrollHeight; }
|
||||||
|
|
||||||
|
function addMsg(from, text, mine, addressed) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "msg " + (mine ? "me" : "them") + (addressed ? " addr" : "");
|
||||||
|
if (!mine) { const w = document.createElement("div"); w.className = "who"; w.textContent = from; el.appendChild(w); }
|
||||||
|
el.appendChild(document.createTextNode(text));
|
||||||
|
const stick = atBottom();
|
||||||
|
log.appendChild(el);
|
||||||
|
if (stick || mine) scroll();
|
||||||
|
}
|
||||||
|
function addSys(text) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "sys"; el.textContent = text;
|
||||||
|
const stick = atBottom();
|
||||||
|
log.appendChild(el); if (stick) scroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(ev) {
|
||||||
|
if (seen.has(ev.seq)) return; seen.add(ev.seq);
|
||||||
|
if (ev.kind === "message") addMsg(ev.from, ev.text, false, ev.addressed);
|
||||||
|
else if (ev.kind === "sent") addMsg(ev.from, ev.text, true, false);
|
||||||
|
else if (ev.kind === "roster") {
|
||||||
|
const u = (ev.users || []); rosterEl.textContent = u.length + " · " + u.join(", ");
|
||||||
|
} else if (ev.kind === "system") {
|
||||||
|
const on = ev.event === "connected"; dot.classList.toggle("on", on);
|
||||||
|
addSys("· " + (ev.event || "") + (ev.reason ? " (" + ev.reason + ")" : "") + " ·");
|
||||||
|
} else if (ev.kind === "sandbox") addSys("· sandbox " + (ev.state || "") + " ·");
|
||||||
|
else if (ev.kind === "acl") addSys("· drive " + (ev.granted ? "granted" : "revoked") + " ·");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStatus() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/status"); const s = await r.json();
|
||||||
|
dot.classList.toggle("on", !!s.connected);
|
||||||
|
if (s.users) rosterEl.textContent = s.users.length + " · " + s.users.join(", ");
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/events?since=" + since);
|
||||||
|
const data = await r.json();
|
||||||
|
for (const ev of (data.events || [])) { since = Math.max(since, ev.seq); render(ev); }
|
||||||
|
} catch (e) {
|
||||||
|
dot.classList.remove("on");
|
||||||
|
await new Promise(res => setTimeout(res, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSend() {
|
||||||
|
const text = box.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
send.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/say", {method: "POST", headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({text})});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) { box.value = ""; box.style.height = "auto"; }
|
||||||
|
else addSys("· send failed: " + (j.error || "?") + " ·");
|
||||||
|
} catch (e) { addSys("· send failed (offline) ·"); }
|
||||||
|
send.disabled = false; box.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
send.addEventListener("click", doSend);
|
||||||
|
box.addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); doSend(); } });
|
||||||
|
box.addEventListener("input", () => { box.style.height = "auto"; box.style.height = Math.min(box.scrollHeight, 120) + "px"; });
|
||||||
|
|
||||||
|
refreshStatus(); poll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user