#!/usr/bin/env python3 """ Robust sidechat management for muse-chat-api.py. Provides: - ensure_sidebar(ws): Opens sidebar if closed, with retry - wait_for_chat_list(ws, name=None): Polls until sidebar list content loads - list_sidechats(ws): Returns list of side chat names, with retry - All operations logged for audit The sidebar UI is flaky — sometimes the open button isn't found, sometimes content takes >2s to load. This module handles that. """ import time import json def _ev(ws, js, await_result=True): """Evaluate JS via CDP, return result. Matches muse-chat-api.py's ev() exactly (id=1, returnByValue). Fixed 2026-10-04: was using id=100, missing returnByValue. """ import json as _json ws.send(_json.dumps({ "id": 1, "method": "Runtime.evaluate", "params": {"expression": js, "returnByValue": True, "awaitPromise": await_result} })) resp = _json.loads(ws.recv()) result = resp.get("result", {}).get("result", {}) return result.get("value") def ensure_sidebar(ws, max_retries=3): """ Ensure the chat panel is open. Returns True if open, False otherwise. Checks for + button presence (not text - avoids false-positive on DM text containing 'Side chats'). Fixed 2026-10-04: was checking innerText.includes('Side chats') which matched DM message text, not the UI. Now checks for [data-testid="hatch-chat-compose"]. """ for attempt in range(max_retries): # Check if panel is open via + button presence (reliable) is_open = _ev(ws, """(() => { return !!document.querySelector('[data-testid="hatch-chat-compose"]'); })()""") if is_open: return True # Open via switcher trigger (idempotent) _ev(ws, """(() => { const btn = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]'); if (btn) btn.click(); return !!btn; })()""") time.sleep(2 * (attempt + 1)) # Exponential backoff: 2s, 4s, 6s # Final check return _ev(ws, """(() => { return !!document.querySelector('[data-testid="hatch-chat-compose"]'); })()""") def wait_for_chat_list(ws, name=None, timeout_s=15): """Poll until the sidebar chat list content has loaded (and optionally contains `name`, case-insensitive substring match). Readiness requires at least one plausible chat-title line under the "Side chats" header -- the header itself renders before items populate, and the compose button that ensure_sidebar() keys on renders earlier still. Clicking a chat title before the list is ready is a known nav-miss contributor (the click lands nowhere and the SPA stays on /). Per-poll CDP errors are treated as not-ready (browser may be mid-render or mid-flap). Returns True when ready, False on timeout. Note: an account with genuinely zero sidechats will always time out here. """ import re as _re want = (name or "").strip().lower() deadline = time.time() + timeout_s while time.time() < deadline: try: text = _ev(ws, "document.body.innerText") or "" except Exception: text = "" idx = text.find("Side chats") if idx != -1: section = text[idx:idx + 2000].lower() lines = [l.strip() for l in section.split("\n")] titles = [l for l in lines[1:21] if 5 < len(l) < 80 and not _re.fullmatch(r"\d+[mh]", l) and l != "unread updates"] if titles and (not want or want in section): return True time.sleep(1) return False def list_sidechats(ws): """ List side chat names. Returns list of strings. Ensures sidebar is open first, then waits for the list content to populate before scraping (the open signal fires before content loads). """ if not ensure_sidebar(ws): return [] if not wait_for_chat_list(ws): return [] result = _ev(ws, """(() => { const text = document.body.innerText; const idx = text.indexOf('Side chats'); if (idx === -1) return JSON.stringify([]); // Extract chat names (heuristic: lines after "Side chats" that look like titles) const section = text.slice(idx, idx + 2000); const lines = section.split('\\n').map(l => l.trim()).filter(l => l.length > 0); // Skip header, collect likely chat titles (skip timestamps like "1m", "2m") const chats = []; for (let i = 1; i < lines.length && chats.length < 20; i++) { const line = lines[i]; if (/^\\d+[mh]$/.test(line)) continue; // timestamp if (line === 'Unread updates') continue; if (line.length > 5 && line.length < 80) chats.push(line); } return JSON.stringify(chats); })()""") try: return json.loads(result) if result else [] except: return [] def log_sidechat_op(agent, operation, details=""): """Log sidechat operations for audit.""" import os from datetime import datetime, timezone log_file = "/home/super/Projects/NetVM/sidechat-log.jsonl" entry = { "ts": datetime.now(timezone.utc).isoformat(), "agent": agent, "op": operation, "details": details[:200] } try: with open(log_file, "a") as f: f.write(json.dumps(entry) + "\n") except: pass