feat(operator): mobile hack-house console — terminal + keys + chat

Rebuild the web UI into a room mirror that looks/feels like the TUI:
tabbed Terminal + Chat, a live shared-sandbox terminal pane (polls
/api/screen, client-side ANSI/SGR render), keystroke drive with quick
keys + a command line (POST /api/keys), roster and a driver/watching
badge. Read-only until the room /grants this operator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-07 16:19:53 -07:00
parent 35da940cc8
commit 1c36325d07
+256 -103
View File
@@ -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 A phone-friendly browser mirror of a live hack-house room. The daemon already
already exposes the room as a local API over its AF_UNIX control socket exposes the room as a local API over its AF_UNIX control socket; this module is
(`read` with long-poll, `say`, `roster`, `status`); this module is a thin, a thin, **stdlib-only** HTTP shim in front of it so a browser gets the same
**stdlib-only** HTTP shim in front of that socket so a browser can drive it: things the Rust TUI shows — the shared sandbox terminal, chat, roster — mobile
optimised:
GET / the single-page chat UI (self-contained HTML/JS) 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} GET /api/events?since=N long-poll: proxies {"op":"read","wait":true}
POST /api/say {text} proxies {"op":"say"} POST /api/say {text} proxies {"op":"say"}
GET /api/status proxies {"op":"status"} (roster + connection) 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 Why this exists: on Termux the tmux `read --wait` loop is read-only and shows
means a second shell typing `operator say …`, which is painful on a touch none of the sandbox. A browser page gives the terminal view + a real input box
keyboard. A browser page gives a real input box, scrollback and live updates + live updates with zero extra dependencies (no server stack, no JS build).
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 Binds 127.0.0.1 by default: the control socket grants room-send/drive rights,
listener stays loopback-only unless you deliberately pass a routable --bind. so the listener stays loopback-only unless you pass a routable --bind.
""" """
from __future__ import annotations from __future__ import annotations
@@ -41,130 +43,264 @@ _PAGE = """<!doctype html>
<title>hack-house · __SESSION__</title> <title>hack-house · __SESSION__</title>
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
* { box-sizing: border-box; } * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; margin: 0; } html, body { height: 100%; margin: 0; }
body { body {
font: 15px/1.4 -apple-system, system-ui, "Segoe UI", Roboto, sans-serif; font: 14px/1.4 -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;
background: #0d1117; color: #e6edf3; background: #0b0e14; color: #cdd6f4; display: flex; flex-direction: column;
display: flex; flex-direction: column; overflow: hidden;
} }
header { header {
padding: 10px 14px; background: #161b22; border-bottom: 1px solid #30363d; padding: 8px 12px; background: #11151f; border-bottom: 1px solid #1f2430;
display: flex; align-items: center; gap: 10px; flex: none; display: flex; align-items: center; gap: 8px; flex: none;
} }
header .dot { width: 10px; height: 10px; border-radius: 50%; background: #f85149; flex: none; } header .dot { width: 9px; height: 9px; border-radius: 50%; background: #f38ba8; flex: none; }
header .dot.on { background: #3fb950; } header .dot.on { background: #a6e3a1; box-shadow: 0 0 6px #a6e3a1; }
header .name { font-weight: 600; } header .name { font-weight: 700; letter-spacing: .3px; }
header .roster { color: #8b949e; font-size: 13px; margin-left: auto; header .badge { font-size: 11px; padding: 1px 7px; border-radius: 10px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } background: #313244; color: #9399b2; }
#log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; } header .badge.drive { background: #1e3a2a; color: #a6e3a1; }
.msg { max-width: 82%; padding: 8px 11px; border-radius: 14px; word-wrap: break-word; white-space: pre-wrap; } header .roster { color: #7f849c; font-size: 12px; margin-left: auto;
.msg .who { font-size: 11px; color: #8b949e; margin-bottom: 2px; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 45%; }
.msg.them { background: #21262d; align-self: flex-start; border-bottom-left-radius: 4px; } .tabs { display: flex; flex: none; background: #11151f; border-bottom: 1px solid #1f2430; }
.msg.me { background: #1f6feb; align-self: flex-end; border-bottom-right-radius: 4px; } .tabs button { flex: 1; background: none; border: 0; color: #7f849c; padding: 9px;
.msg.addr { box-shadow: 0 0 0 2px #d29922 inset; } font: inherit; font-weight: 600; border-bottom: 2px solid transparent; }
.sys { align-self: center; color: #8b949e; font-size: 12px; font-style: italic; } .tabs button.active { color: #89b4fa; border-bottom-color: #89b4fa; }
footer { flex: none; display: flex; gap: 8px; padding: 10px; background: #161b22; border-top: 1px solid #30363d; } .pane { flex: 1; min-height: 0; display: none; flex-direction: column; }
#box { flex: 1; resize: none; background: #0d1117; color: #e6edf3; border: 1px solid #30363d; .pane.active { display: flex; }
border-radius: 10px; padding: 10px; font: inherit; max-height: 120px; }
#send { flex: none; background: #238636; color: #fff; border: 0; border-radius: 10px; /* terminal */
padding: 0 18px; font: inherit; font-weight: 600; } #term { flex: 1; margin: 0; padding: 10px; overflow: auto; white-space: pre;
font: 12px/1.35 "SF Mono", ui-monospace, Menlo, Consolas, monospace;
background: #05070c; color: #b9c0d4; }
#term .empty { color: #565a70; font-style: italic; }
.keys { flex: none; background: #11151f; border-top: 1px solid #1f2430; padding: 6px; }
.quick { display: flex; gap: 5px; overflow-x: auto; padding-bottom: 6px; }
.quick button { flex: none; background: #1f2430; color: #cdd6f4; border: 1px solid #313244;
border-radius: 7px; padding: 6px 9px; font: 12px monospace; }
.quick button:active { background: #313244; }
.keyrow { display: flex; gap: 6px; }
#kbox { flex: 1; background: #05070c; color: #b9c0d4; border: 1px solid #313244;
border-radius: 8px; padding: 8px; font: 13px monospace; }
#kbox:disabled { opacity: .5; }
#ksend { flex: none; background: #89b4fa; color: #0b0e14; border: 0; border-radius: 8px;
padding: 0 14px; font: inherit; font-weight: 700; }
#ksend:disabled { opacity: .4; }
.hint { color: #f9e2af; font-size: 11px; padding: 4px 2px 0; }
/* chat */
#log { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 7px; }
.msg { max-width: 82%; padding: 7px 10px; border-radius: 13px; word-wrap: break-word; white-space: pre-wrap; }
.msg .who { font-size: 10px; color: #7f849c; margin-bottom: 2px; }
.msg.them { background: #1f2430; align-self: flex-start; border-bottom-left-radius: 4px; }
.msg.me { background: #3a4a7a; align-self: flex-end; border-bottom-right-radius: 4px; }
.msg.addr { box-shadow: 0 0 0 2px #f9e2af inset; }
.sys { align-self: center; color: #7f849c; font-size: 11px; font-style: italic; }
footer { flex: none; display: flex; gap: 7px; padding: 8px; background: #11151f; border-top: 1px solid #1f2430; }
#box { flex: 1; resize: none; background: #05070c; color: #cdd6f4; border: 1px solid #313244;
border-radius: 9px; padding: 9px; font: inherit; max-height: 110px; }
#send { flex: none; background: #a6e3a1; color: #0b0e14; border: 0; border-radius: 9px;
padding: 0 16px; font: inherit; font-weight: 700; }
#send:disabled { opacity: .5; } #send:disabled { opacity: .5; }
/* ANSI palette */
.a30{color:#45475a}.a31{color:#f38ba8}.a32{color:#a6e3a1}.a33{color:#f9e2af}
.a34{color:#89b4fa}.a35{color:#f5c2e7}.a36{color:#94e2d5}.a37{color:#bac2de}
.a90{color:#585b70}.a91{color:#f38ba8}.a92{color:#a6e3a1}.a93{color:#f9e2af}
.a94{color:#89b4fa}.a95{color:#f5c2e7}.a96{color:#94e2d5}.a97{color:#e6edf3}
.ab{font-weight:700}
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<span class="dot" id="dot"></span> <span class="dot" id="dot"></span>
<span class="name">__SESSION__</span> <span class="name">__SESSION__</span>
<span class="badge" id="drive">watching</span>
<span class="roster" id="roster">—</span> <span class="roster" id="roster">—</span>
</header> </header>
<div class="tabs">
<button id="tabTerm" class="active">Terminal</button>
<button id="tabChat">Chat</button>
</div>
<div class="pane active" id="paneTerm">
<pre id="term"><span class="empty">no shared sandbox yet — a room member runs /sbx and /grants this operator to drive it here.</span></pre>
<div class="keys">
<div class="quick">
<button data-k="enter">⏎ Enter</button>
<button data-k="ctrl-c">^C</button>
<button data-k="esc">Esc</button>
<button data-k="tab">Tab</button>
<button data-k="up">↑</button>
<button data-k="down">↓</button>
<button data-k="ctrl-l">clear</button>
<button data-k="ctrl-d">^D</button>
</div>
<div class="keyrow">
<input id="kbox" placeholder="type a command → Send runs it (adds Enter)" autocomplete="off" autocapitalize="off" spellcheck="false">
<button id="ksend">Send</button>
</div>
<div class="hint" id="khint"></div>
</div>
</div>
<div class="pane" id="paneChat">
<div id="log"></div> <div id="log"></div>
<footer> <footer>
<textarea id="box" rows="1" placeholder="Message the room…" autocomplete="off"></textarea> <textarea id="box" rows="1" placeholder="Message the room…" autocomplete="off"></textarea>
<button id="send">Send</button> <button id="send">Send</button>
</footer> </footer>
</div>
<script> <script>
const ME = "__SESSION__"; const ME = "__SESSION__";
const log = document.getElementById("log"); const $ = id => document.getElementById(id);
const box = document.getElementById("box"); let since = 0, granted = false, active = "term", termSeq = -1;
const send = document.getElementById("send");
const dot = document.getElementById("dot");
const rosterEl = document.getElementById("roster");
let since = 0;
const seen = new Set(); const seen = new Set();
function atBottom() { return log.scrollHeight - log.scrollTop - log.clientHeight < 60; } /* ── tabs ── */
function scroll() { log.scrollTop = log.scrollHeight; } function show(tab) {
active = tab;
$("tabTerm").classList.toggle("active", tab === "term");
$("tabChat").classList.toggle("active", tab === "chat");
$("paneTerm").classList.toggle("active", tab === "term");
$("paneChat").classList.toggle("active", tab === "chat");
}
$("tabTerm").onclick = () => show("term");
$("tabChat").onclick = () => show("chat");
/* ── ANSI (SGR) → HTML for the terminal pane ── */
function esc(s){return s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
function ansiToHtml(raw) {
let out = "", open = false, cls = [];
const re = /\\x1b\\[([0-9;]*)m|\\x1b\\[[0-9;?]*[A-Za-z]|\\x1b\\][^\\x07]*\\x07/g;
let last = 0, m;
const flush = t => { if (!t) return; out += esc(t); };
while ((m = re.exec(raw)) !== null) {
flush(raw.slice(last, m.index)); last = re.lastIndex;
if (m[1] === undefined) continue; // non-SGR CSI / OSC → drop
const codes = m[1] === "" ? [0] : m[1].split(";").map(Number);
if (open) { out += "</span>"; open = false; }
cls = [];
for (const c of codes) {
if (c === 0) cls = [];
else if (c === 1) cls.push("ab");
else if ((c>=30&&c<=37)||(c>=90&&c<=97)) cls.push("a"+c);
}
if (cls.length) { out += '<span class="'+cls.join(" ")+'">'; open = true; }
}
flush(raw.slice(last));
if (open) out += "</span>";
return out;
}
/* ── terminal poll ── */
async function pollScreen() {
for (;;) {
try {
if (active === "term") {
const r = await fetch("/api/screen?ansi=1");
const d = await r.json();
if (d.ok && d.seq !== termSeq) {
termSeq = d.seq;
const t = $("term");
const bottom = t.scrollHeight - t.scrollTop - t.clientHeight < 40;
if (d.text && d.text.length) t.innerHTML = ansiToHtml(d.text);
if (bottom) t.scrollTop = t.scrollHeight;
}
}
} catch (e) {}
await new Promise(r => setTimeout(r, active === "term" ? 900 : 2000));
}
}
/* ── keys ── */
async function sendKeys(tokens) {
if (!granted) return;
try {
const r = await fetch("/api/keys", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({tokens})});
const j = await r.json();
if (!j.ok) $("khint").textContent = "keys: " + (j.error || "?");
} catch (e) { $("khint").textContent = "keys: offline"; }
}
document.querySelectorAll(".quick button").forEach(b =>
b.onclick = () => sendKeys([b.dataset.k]));
function runLine() {
const v = $("kbox").value;
if (!granted) return;
sendKeys(v ? ["text:" + v, "enter"] : ["enter"]);
$("kbox").value = "";
}
$("ksend").onclick = runLine;
$("kbox").addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); runLine(); } });
function setGrant(g) {
granted = g;
$("drive").textContent = g ? "driver" : "watching";
$("drive").classList.toggle("drive", g);
$("kbox").disabled = !g; $("ksend").disabled = !g;
document.querySelectorAll(".quick button").forEach(b => b.disabled = !g);
$("khint").textContent = g ? "" : "read-only — a room owner must /grant " + ME + " to drive the sandbox";
}
/* ── chat ── */
const log = $("log"), box = $("box"), send = $("send"), dot = $("dot"), rosterEl = $("roster");
function atBottom(){ return log.scrollHeight - log.scrollTop - log.clientHeight < 60; }
function addMsg(from, text, mine, addressed) { function addMsg(from, text, mine, addressed) {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "msg " + (mine ? "me" : "them") + (addressed ? " addr" : ""); el.className = "msg " + (mine ? "me" : "them") + (addressed ? " addr" : "");
if (!mine) { const w = document.createElement("div"); w.className = "who"; w.textContent = from; el.appendChild(w); } if (!mine){ const w=document.createElement("div"); w.className="who"; w.textContent=from; el.appendChild(w); }
el.appendChild(document.createTextNode(text)); el.appendChild(document.createTextNode(text));
const stick = atBottom(); const stick = atBottom(); log.appendChild(el); if (stick||mine) log.scrollTop = log.scrollHeight;
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 addSys(text){ const el=document.createElement("div"); el.className="sys"; el.textContent=text;
const stick=atBottom(); log.appendChild(el); if(stick) log.scrollTop=log.scrollHeight; }
function setRoster(u){ rosterEl.textContent = (u||[]).length + " · " + (u||[]).join(", "); }
function render(ev) { function render(ev) {
if (seen.has(ev.seq)) return; seen.add(ev.seq); if (seen.has(ev.seq)) return; seen.add(ev.seq);
if (ev.kind === "message") addMsg(ev.from, ev.text, false, ev.addressed); 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 === "sent") addMsg(ev.from, ev.text, true, false);
else if (ev.kind === "roster") { else if (ev.kind === "roster") setRoster(ev.users);
const u = (ev.users || []); rosterEl.textContent = u.length + " · " + u.join(", "); else if (ev.kind === "system") {
} else if (ev.kind === "system") { if (ev.event === "connected") dot.classList.add("on");
const on = ev.event === "connected"; dot.classList.toggle("on", on); if (ev.event === "reconnecting") dot.classList.remove("on");
addSys("· " + (ev.event || "") + (ev.reason ? " (" + ev.reason + ")" : "") + " ·"); addSys("· " + (ev.event||"") + (ev.reason ? " ("+ev.reason+")" : "") + " ·");
} else if (ev.kind === "sandbox") addSys("· sandbox " + (ev.state || "") + " ·"); } else if (ev.kind === "acl") { setGrant(!!ev.granted); addSys("· drive "+(ev.granted?"granted":"revoked")+" ·"); }
else if (ev.kind === "acl") addSys("· drive " + (ev.granted ? "granted" : "revoked") + " ·"); else if (ev.kind === "sandbox") addSys("· sandbox " + (ev.state||"") + " ·");
} }
async function refreshStatus() { async function refreshStatus() {
try { try { const s = await (await fetch("/api/status")).json();
const r = await fetch("/api/status"); const s = await r.json();
dot.classList.toggle("on", !!s.connected); dot.classList.toggle("on", !!s.connected);
if (s.users) rosterEl.textContent = s.users.length + " · " + s.users.join(", "); if (s.users) setRoster(s.users);
setGrant(!!s.granted);
} catch (e) {} } catch (e) {}
} }
async function pollEvents() {
async function poll() {
for (;;) { for (;;) {
try { try {
const r = await fetch("/api/events?since=" + since); const d = await (await fetch("/api/events?since=" + since)).json();
const data = await r.json(); for (const ev of (d.events || [])) { since = Math.max(since, ev.seq); render(ev); }
for (const ev of (data.events || [])) { since = Math.max(since, ev.seq); render(ev); } } catch (e) { dot.classList.remove("on"); await new Promise(r=>setTimeout(r,2000)); }
} catch (e) {
dot.classList.remove("on");
await new Promise(res => setTimeout(res, 2000));
}
} }
} }
async function doSend() { async function doSend() {
const text = box.value.trim(); const text = box.value.trim(); if (!text) return;
if (!text) return;
send.disabled = true; send.disabled = true;
try { try {
const r = await fetch("/api/say", {method: "POST", headers: {"Content-Type": "application/json"}, const j = await (await fetch("/api/say",{method:"POST",headers:{"Content-Type":"application/json"},
body: JSON.stringify({text})}); body:JSON.stringify({text})})).json();
const j = await r.json(); if (j.ok){ box.value=""; box.style.height="auto"; } else addSys("· send failed: "+(j.error||"?")+" ·");
if (j.ok) { box.value = ""; box.style.height = "auto"; } } catch(e){ addSys("· send failed (offline) ·"); }
else addSys("· send failed: " + (j.error || "?") + " ·");
} catch (e) { addSys("· send failed (offline) ·"); }
send.disabled = false; box.focus(); send.disabled = false; box.focus();
} }
send.onclick = 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,110)+"px"; });
send.addEventListener("click", doSend); refreshStatus(); pollEvents(); pollScreen();
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> </script>
</body> </body>
</html> </html>
@@ -175,7 +311,6 @@ class _Handler(BaseHTTPRequestHandler):
session_name = "default" # set on the class before serving session_name = "default" # set on the class before serving
protocol_version = "HTTP/1.1" protocol_version = "HTTP/1.1"
# ── plumbing ─────────────────────────────────────────────────────────
def log_message(self, *a): # silence per-request stderr spam def log_message(self, *a): # silence per-request stderr spam
pass pass
@@ -199,7 +334,7 @@ class _Handler(BaseHTTPRequestHandler):
# ── routes ─────────────────────────────────────────────────────────── # ── routes ───────────────────────────────────────────────────────────
def do_GET(self) -> None: def do_GET(self) -> None:
url = urlparse(self.path) 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) page = _PAGE.replace("__SESSION__", resolve(self.session_name).name)
body = page.encode() body = page.encode()
self.send_response(200) self.send_response(200)
@@ -211,32 +346,50 @@ class _Handler(BaseHTTPRequestHandler):
if url.path == "/api/status": if url.path == "/api/status":
self._send_json(self._bridge({"op": "status"}, read_timeout=8)) self._send_json(self._bridge({"op": "status"}, read_timeout=8))
return 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": if url.path == "/api/events":
q = parse_qs(url.query) q = parse_qs(url.query)
since = int((q.get("since") or ["0"])[0]) 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}, {"op": "read", "since": since, "wait": True, "timeout": _POLL_TIMEOUT},
read_timeout=_POLL_TIMEOUT + 5.0) read_timeout=_POLL_TIMEOUT + 5.0))
self._send_json(resp)
return return
self._send_json({"ok": False, "error": "not found"}, code=404) self._send_json({"ok": False, "error": "not found"}, code=404)
def do_POST(self) -> None: def _read_body(self) -> dict:
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) length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}" raw = self.rfile.read(length) if length else b"{}"
try: try:
text = str(json.loads(raw.decode()).get("text", "")).strip() obj = json.loads(raw.decode())
except (ValueError, AttributeError): return obj if isinstance(obj, dict) else {}
self._send_json({"ok": False, "error": "bad json"}, code=400) except ValueError:
return 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: if not text:
self._send_json({"ok": False, "error": "empty"}, code=400) self._send_json({"ok": False, "error": "empty"}, code=400)
return return
self._send_json(self._bridge({"op": "say", "text": text}, read_timeout=15)) self._send_json(self._bridge({"op": "say", "text": text}, read_timeout=15))
return
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({"ok": False, "error": "not found"}, code=404)
def serve(session_name: str, bind: str, port: int) -> int: 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 = ThreadingHTTPServer((bind, port), _Handler)
httpd.daemon_threads = True httpd.daemon_threads = True
shown = bind if bind not in ("0.0.0.0", "::") else "127.0.0.1" 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: try:
httpd.serve_forever() httpd.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nweb UI stopped") print("\nweb console stopped")
finally: finally:
httpd.server_close() httpd.server_close()
return 0 return 0