feat(tui): SGR mouse fallback, focus partition highlights, multi-trigger context menus & box tui
- bin/muse-tui.py: * Parse raw SGR 1006 (\033[<btn;x;yM/m) and Xterm mouse escape sequences in _handle_escape_sequence fallback. * Multi-trigger context menus: Button 3, Button 2, Ctrl/Shift/Alt+Click, double-click, click on active item, or click [⚡] / [sid] target. * Render permanent [⚡] action target across all sidebar thread rows. * Separate focus partitions for FLEET AGENTS and SIDECHATS with partition-specific wheel scrolling and keyboard navigation (j/k, Enter, h/l). * Space key support in NORMAL mode to open context menus. * Active pane highlighting and updated footer hints. - bin/super-cli.py: * Add 'box tui' command dispatching directly to muse-tui.py --mode box. - tests: * Add unit tests in test_context_menus.py, test_focus_highlight.py, and test_main_nav.py (51/51 passing).
This commit is contained in:
+597
-109
File diff suppressed because it is too large
Load Diff
+833
-8
@@ -6,7 +6,9 @@ autonomous browser agents (muse, pip, 646, opm) on bl and interface
|
|||||||
with the Google Cloud VM web surfaces.
|
with the Google Cloud VM web surfaces.
|
||||||
|
|
||||||
Domains:
|
Domains:
|
||||||
super fleet — Node health, CDP status, active tabs, watch, restart
|
super muse-choices — Muse TUI A/B/C auto-answer daemon (on/off/status/logs)
|
||||||
|
super fleet — Node health, CDP status, active tabs, watch, restart, heal
|
||||||
|
super watchdog — Watchdog timers: status, trigger runs
|
||||||
super dm — Inter-agent DMs, work orders ([WO]), acks, live log tail
|
super dm — Inter-agent DMs, work orders ([WO]), acks, live log tail
|
||||||
super thread — Inspect agent main chats, sidechats, and scrollbacks
|
super thread — Inspect agent main chats, sidechats, and scrollbacks
|
||||||
super job — Manage scheduled jobs, systemd timers, triggers, logs
|
super job — Manage scheduled jobs, systemd timers, triggers, logs
|
||||||
@@ -16,7 +18,8 @@ Domains:
|
|||||||
super vars — Shortcut to inspect & adjust runtime control variables
|
super vars — Shortcut to inspect & adjust runtime control variables
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
super fleet [status|watch|restart|cdp]
|
super fleet [status|watch|restart|cdp|heal]
|
||||||
|
super watchdog [status|run <node|relay>]
|
||||||
super dm [send|wo|ack|log|tail]
|
super dm [send|wo|ack|log|tail]
|
||||||
super thread [list|view]
|
super thread [list|view]
|
||||||
super job [list|status|run|log|create|enable|disable|delete]
|
super job [list|status|run|log|create|enable|disable|delete]
|
||||||
@@ -392,7 +395,7 @@ def cmd_fleet_status(args):
|
|||||||
print(c_dim(" [*] status via host watchdog evidence (local probes blind in this shell)"))
|
print(c_dim(" [*] status via host watchdog evidence (local probes blind in this shell)"))
|
||||||
if has_any_approval:
|
if has_any_approval:
|
||||||
print("\n" + c_yellow(" ⚠ Agent(s) held up on browser approval. Run 'box approvals' to inspect/allow."))
|
print("\n" + c_yellow(" ⚠ Agent(s) held up on browser approval. Run 'box approvals' to inspect/allow."))
|
||||||
print("\n" + c_dim(" Commands: super fleet watch | super fleet restart <node> | box approvals [check|allow|auto]") + "\n")
|
print("\n" + c_dim(" Commands: super fleet watch | super fleet heal <node> | box approvals [check|allow|auto]") + "\n")
|
||||||
|
|
||||||
def cmd_fleet_watch(args):
|
def cmd_fleet_watch(args):
|
||||||
interval = getattr(args, "interval", 2)
|
interval = getattr(args, "interval", 2)
|
||||||
@@ -447,6 +450,204 @@ def cmd_fleet_cdp(args):
|
|||||||
print(f" SSH Forward : {c_yellow(f'ssh -L {port}:{peer}:{port} super@100.123.153.75')}")
|
print(f" SSH Forward : {c_yellow(f'ssh -L {port}:{peer}:{port} super@100.123.153.75')}")
|
||||||
print(f" Local Connect : {c_dim(f'http://127.0.0.1:{port}')} (after forwarding)\n")
|
print(f" Local Connect : {c_dim(f'http://127.0.0.1:{port}')} (after forwarding)\n")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain: FLEET HEAL + WATCHDOG
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SYSTEMD_SYSTEM_DIR = Path("/etc/systemd/system")
|
||||||
|
WATCHDOG_LOCK_TMPL = "/tmp/chromebox-watchdog-{node}.lock"
|
||||||
|
WARP_PROBE_URL = "https://1.1.1.1/cdn-cgi/trace"
|
||||||
|
CHROMEBOX_TIMER_TMPL = """[Unit]
|
||||||
|
Description=Run chromebox watchdog for {node} every 2 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
RandomizedDelaySec=30s
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=2min
|
||||||
|
Unit=chromebox-watchdog@{node}.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _sh(cmd, timeout=120, input_text=None):
|
||||||
|
"""Run cmd, capture output. Returns (returncode, combined_output)."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True,
|
||||||
|
timeout=timeout, input=input_text)
|
||||||
|
return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip()
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return 124, "timed out after %ds: %s" % (timeout, " ".join(cmd))
|
||||||
|
except OSError as e:
|
||||||
|
return 127, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _heal_lock_step(node):
|
||||||
|
"""Clear a stale unwritable watchdog lock (blocks watchdog startup)."""
|
||||||
|
lock = Path(WATCHDOG_LOCK_TMPL.format(node=node))
|
||||||
|
if not lock.exists() or os.access(lock, os.W_OK):
|
||||||
|
return True, "lock writable or absent"
|
||||||
|
rc, out = _sh(["sudo", "rm", "-f", str(lock)], timeout=30)
|
||||||
|
if rc == 0:
|
||||||
|
return True, "removed stale lock %s" % lock
|
||||||
|
return False, "cannot remove %s: %s" % (lock, out)
|
||||||
|
|
||||||
|
|
||||||
|
def _heal_timer_step(node):
|
||||||
|
"""Install (if missing) and enable the node's watchdog timer."""
|
||||||
|
timer = "chromebox-watchdog-%s.timer" % node
|
||||||
|
path = SYSTEMD_SYSTEM_DIR / timer
|
||||||
|
if not path.exists():
|
||||||
|
rc, out = _sh(["sudo", "tee", str(path)], timeout=30,
|
||||||
|
input_text=CHROMEBOX_TIMER_TMPL.format(node=node))
|
||||||
|
if rc != 0:
|
||||||
|
return False, "cannot install %s: %s" % (timer, out)
|
||||||
|
rc, out = _sh(["sudo", "systemctl", "daemon-reload"], timeout=60)
|
||||||
|
if rc != 0:
|
||||||
|
return False, "daemon-reload failed: %s" % out
|
||||||
|
rc, out = _sh(["sudo", "systemctl", "enable", "--now", timer], timeout=60)
|
||||||
|
if rc != 0:
|
||||||
|
return False, "enable --now failed: %s" % out
|
||||||
|
return True, "timer installed+active"
|
||||||
|
|
||||||
|
|
||||||
|
def _heal_tunnel_step(node):
|
||||||
|
"""Restart the node's Warp tunnel + CDP relay via netvm-node-up.sh."""
|
||||||
|
rc, out = _sh(["sudo", str(BIN_DIR / "netvm-node-up.sh"), node], timeout=240)
|
||||||
|
last = out.strip().splitlines()[-1] if out.strip() else "(no output)"
|
||||||
|
if rc != 0:
|
||||||
|
return False, "netvm-node-up.sh rc=%d: %s" % (rc, last)
|
||||||
|
if "egress=unknown" in out or "no handshake yet" in out:
|
||||||
|
return False, last
|
||||||
|
return True, last
|
||||||
|
|
||||||
|
|
||||||
|
def _warp_egress_ok(node):
|
||||||
|
"""True when the node's netns reaches the warp probe URL."""
|
||||||
|
rc, out = _sh(["sudo", "ip", "netns", "exec", "warp-%s" % node,
|
||||||
|
"curl", "-sk", "--max-time", "10", WARP_PROBE_URL],
|
||||||
|
timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
tail = out.splitlines()[-1] if out else "curl failed"
|
||||||
|
return False, tail
|
||||||
|
for line in out.splitlines():
|
||||||
|
if line.startswith("ip="):
|
||||||
|
return True, line
|
||||||
|
return False, "no ip= line in probe response"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_fleet_heal(args):
|
||||||
|
node = args.node
|
||||||
|
if node not in VALID_NODES:
|
||||||
|
print(f"Error: Unknown node '{node}'. Valid: {', '.join(VALID_NODES)}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
as_json = getattr(args, "json", False)
|
||||||
|
info = get_node_network_info(node)
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
def record(step, ok, detail):
|
||||||
|
steps.append({"step": step, "ok": ok, "detail": detail})
|
||||||
|
if not as_json:
|
||||||
|
mark = c_green("ok") if ok else c_red("FAIL")
|
||||||
|
print(f" [{mark}] {step}: {detail}")
|
||||||
|
|
||||||
|
if not as_json:
|
||||||
|
print(f"\n{c_bold('Healing node ' + node)} ({info['netns']}, {info['peer_ip']}:{info['cdp_port']})")
|
||||||
|
|
||||||
|
record("lock", *_heal_lock_step(node))
|
||||||
|
record("timer", *_heal_timer_step(node))
|
||||||
|
record("tunnel", *_heal_tunnel_step(node))
|
||||||
|
egress_ok, egress_detail = _warp_egress_ok(node)
|
||||||
|
record("egress", egress_ok, egress_detail)
|
||||||
|
relay = probe_cdp_status(info["peer_ip"], info["cdp_port"])
|
||||||
|
if relay["ok"]:
|
||||||
|
record("relay", True, "CDP answers in %sms" % relay["latency_ms"])
|
||||||
|
else:
|
||||||
|
record("relay", False, relay.get("error", "unreachable"))
|
||||||
|
|
||||||
|
verdict = "RECOVERED" if egress_ok else "DOWN"
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({"ok": egress_ok, "node": node,
|
||||||
|
"verdict": verdict, "steps": steps}, indent=2))
|
||||||
|
else:
|
||||||
|
print(f"\n Verdict: {c_green(verdict) if egress_ok else c_red(verdict)}")
|
||||||
|
if not egress_ok:
|
||||||
|
print(c_dim(" Tunnel still down after restart — if it persists, the Warp identity "
|
||||||
|
f"likely needs regenerating (human: netvm-new-identity.sh {node})"))
|
||||||
|
print()
|
||||||
|
sys.exit(0 if egress_ok else 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_state(unit):
|
||||||
|
"""(enabled, active) booleans for a systemd unit."""
|
||||||
|
rc_e, _ = _sh(["systemctl", "is-enabled", unit], timeout=15)
|
||||||
|
rc_a, _ = _sh(["systemctl", "is-active", unit], timeout=15)
|
||||||
|
return rc_e == 0, rc_a == 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_watchdog_status(args):
|
||||||
|
as_json = getattr(args, "json", False)
|
||||||
|
try:
|
||||||
|
import host_evidence
|
||||||
|
ev = host_evidence.collect()
|
||||||
|
except Exception:
|
||||||
|
ev = {}
|
||||||
|
data = []
|
||||||
|
for node in VALID_NODES:
|
||||||
|
timer = "chromebox-watchdog-%s.timer" % node
|
||||||
|
enabled, active = _unit_state(timer)
|
||||||
|
node_ev = ev.get(node, {})
|
||||||
|
data.append({
|
||||||
|
"node": node,
|
||||||
|
"timer": timer,
|
||||||
|
"enabled": enabled,
|
||||||
|
"active": active,
|
||||||
|
"browser": node_ev.get("browser", "unknown"),
|
||||||
|
"browser_detail": node_ev.get("browser_detail", ""),
|
||||||
|
"cdp": node_ev.get("cdp", "unknown"),
|
||||||
|
"cdp_detail": node_ev.get("cdp_detail", ""),
|
||||||
|
})
|
||||||
|
relay_enabled, relay_active = _unit_state("cdp-relay-watchdog.timer")
|
||||||
|
relay = {"timer": "cdp-relay-watchdog.timer",
|
||||||
|
"enabled": relay_enabled, "active": relay_active}
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({"ok": True, "nodes": data, "relay": relay}, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n" + c_bold("=== WATCHDOG STATUS ===") + c_dim(f" ({datetime.now().strftime('%H:%M:%S')} local)\n"))
|
||||||
|
headers = ["NODE", "TIMER", "BROWSER", "CDP"]
|
||||||
|
rows = []
|
||||||
|
for item in data:
|
||||||
|
timer = badge_ok("active") if item["active"] else (
|
||||||
|
badge_warn("ENABLED") if item["enabled"] else badge_dim("missing"))
|
||||||
|
rows.append([c_bold(item["node"]), timer,
|
||||||
|
item["browser"], item["cdp"]])
|
||||||
|
print_table(headers, rows)
|
||||||
|
rmark = badge_ok("active") if relay["active"] else badge_dim("missing")
|
||||||
|
print(f" relay: {rmark} {c_dim('cdp-relay-watchdog.timer')}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_watchdog_run(args):
|
||||||
|
target = args.target
|
||||||
|
as_json = getattr(args, "json", False)
|
||||||
|
if target == "relay":
|
||||||
|
unit = "cdp-relay-watchdog.service"
|
||||||
|
elif target in VALID_NODES:
|
||||||
|
unit = "chromebox-watchdog@%s.service" % target
|
||||||
|
else:
|
||||||
|
print("Error: Specify node or 'relay' (e.g. box watchdog run dev)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
rc, out = _sh(["sudo", "systemctl", "start", unit], timeout=180)
|
||||||
|
tail = out[-500:] if out else ""
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({"ok": rc == 0, "unit": unit, "detail": tail}, indent=2))
|
||||||
|
else:
|
||||||
|
if rc == 0:
|
||||||
|
print(c_green(f"Watchdog run triggered: {unit}"))
|
||||||
|
else:
|
||||||
|
print(c_red(f"Failed to start {unit}: {tail}"))
|
||||||
|
sys.exit(0 if rc == 0 else 1)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Domain: APPROVALS
|
# Domain: APPROVALS
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -729,6 +930,136 @@ def cmd_approvals(args):
|
|||||||
print(f" Reason: {c_cyan(reason)}")
|
print(f" Reason: {c_cyan(reason)}")
|
||||||
print(c_dim(f" Operator can approve with: box approvals allow {node}"))
|
print(c_dim(f" Operator can approve with: box approvals allow {node}"))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain: MUSE-CHOICES (Muse TUI A/B/C auto-answer daemon)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def cmd_muse_choices(args):
|
||||||
|
import muse_choice_watcher as mcw
|
||||||
|
action = getattr(args, "mc_action", None) or "status"
|
||||||
|
as_json = getattr(args, "json", False)
|
||||||
|
caller = os.environ.get("BOX_CALLER") or getattr(args, "from_agent", None) or "super"
|
||||||
|
|
||||||
|
if action == "on":
|
||||||
|
dry_run = getattr(args, "dry_run", False)
|
||||||
|
state = mcw.set_enabled(True, dry_run=dry_run, by=caller)
|
||||||
|
res = mcw.reconcile()
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({"ok": True, "desired": state, "reconcile": res}, indent=2))
|
||||||
|
return
|
||||||
|
mode = "DRY-RUN (logging only)" if dry_run else c_yellow("LIVE (typing A into Muse panes)")
|
||||||
|
print(c_green("\n✔ Muse choice auto-answers ON.") + f" [{mode}]\n")
|
||||||
|
for s in res["started"]:
|
||||||
|
print(f" {badge_ok('STARTED')} {c_cyan(s)}")
|
||||||
|
for s in res["already"]:
|
||||||
|
print(f" {badge_dim('RUNNING')} {s}")
|
||||||
|
for s in res.get("failed") or []:
|
||||||
|
print(f" {badge_err('FAILED')} {s}")
|
||||||
|
if not res["started"] and not res["already"] and not res.get("failed"):
|
||||||
|
print(c_dim(" No Muse panes found on known tmux sockets."))
|
||||||
|
print()
|
||||||
|
|
||||||
|
elif action == "off":
|
||||||
|
state = mcw.set_enabled(False, by=caller)
|
||||||
|
stopped = mcw.stop_all()
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({"ok": True, "desired": state, "stopped": stopped}, indent=2))
|
||||||
|
return
|
||||||
|
print(c_green("\n✔ Muse choice auto-answers OFF."))
|
||||||
|
for r in stopped:
|
||||||
|
if r.get("status") == "stopped-orphan":
|
||||||
|
label = "(orphan %s:%s)" % (r.get("socket"), r.get("pane"))
|
||||||
|
else:
|
||||||
|
label = r.get("pidfile")
|
||||||
|
print(f" {badge_dim('STOPPED')} {label} (pid {r.get('pid')})")
|
||||||
|
if not stopped:
|
||||||
|
print(c_dim(" No watchers were running."))
|
||||||
|
print()
|
||||||
|
|
||||||
|
elif action == "reconcile":
|
||||||
|
res = mcw.reconcile()
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({"ok": True, "reconcile": res}, indent=2))
|
||||||
|
return
|
||||||
|
print(f"\n Enabled: {c_bold(str(res['enabled']))} Dry-run: {res['dry_run']}")
|
||||||
|
for s in res["started"]:
|
||||||
|
print(f" {badge_ok('STARTED')} {c_cyan(s)}")
|
||||||
|
for s in res["stopped"]:
|
||||||
|
print(f" {badge_dim('STOPPED')} {s}")
|
||||||
|
for s in res["pruned"]:
|
||||||
|
print(f" {badge_dim('PRUNED')} {s}")
|
||||||
|
for s in res.get("failed") or []:
|
||||||
|
print(f" {badge_err('FAILED')} {s}")
|
||||||
|
if not res["started"] and not res["stopped"] and not res["pruned"] and not res.get("failed"):
|
||||||
|
print(c_dim(" Already in desired state."))
|
||||||
|
print()
|
||||||
|
|
||||||
|
elif action == "logs":
|
||||||
|
sock = getattr(args, "socket", None)
|
||||||
|
pane = getattr(args, "pane", None)
|
||||||
|
n = getattr(args, "n", 20)
|
||||||
|
path = mcw.logfile_for(sock, pane)
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
except OSError:
|
||||||
|
print(c_red(f"Error: no log at {path}"), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if as_json:
|
||||||
|
recs = []
|
||||||
|
for line in lines[-n:]:
|
||||||
|
try:
|
||||||
|
recs.append(json.loads(line))
|
||||||
|
except Exception:
|
||||||
|
recs.append({"raw": line.rstrip("\n")})
|
||||||
|
print(json.dumps({"ok": True, "log": path, "records": recs}, indent=2))
|
||||||
|
return
|
||||||
|
print(c_bold(f"\n=== {path} (last {n}) ===\n"))
|
||||||
|
for line in lines[-n:]:
|
||||||
|
print(line.rstrip("\n"))
|
||||||
|
print()
|
||||||
|
|
||||||
|
else: # status
|
||||||
|
desired = mcw.get_desired()
|
||||||
|
watchers = mcw.status_all()
|
||||||
|
answers = mcw.recent_answers(5)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({"ok": True, "desired": desired,
|
||||||
|
"watchers": watchers,
|
||||||
|
"recent_answers": answers}, indent=2))
|
||||||
|
return
|
||||||
|
state_badge = badge_ok("ON") if desired["enabled"] else badge_dim("OFF")
|
||||||
|
dry = " (dry-run)" if desired.get("dry_run") else ""
|
||||||
|
print("\n" + c_bold("=== MUSE CHOICE AUTO-ANSWERS ===") + f" {state_badge}{dry}")
|
||||||
|
upd = desired.get("updated_at") or "-"
|
||||||
|
by = desired.get("updated_by") or "-"
|
||||||
|
print(c_dim(f" Desired state: enabled={desired['enabled']} dry_run={desired.get('dry_run')} updated={upd} by={by}\n"))
|
||||||
|
if watchers:
|
||||||
|
headers = ["WATCHER", "STATUS", "PID", "LOG BYTES"]
|
||||||
|
rows = []
|
||||||
|
for w in watchers:
|
||||||
|
if w.get("orphan"):
|
||||||
|
badge = badge_warn("ORPHAN")
|
||||||
|
name = "(orphan %s:%s)" % (w.get("socket"), w.get("pane"))
|
||||||
|
else:
|
||||||
|
badge = badge_ok("ALIVE") if w["alive"] else badge_err("DEAD")
|
||||||
|
name = w["pidfile"]
|
||||||
|
rows.append([name, badge, str(w["pid"] or "-"),
|
||||||
|
str(w["log_bytes"] if w["log_bytes"] is not None else "-")])
|
||||||
|
print_table(headers, rows)
|
||||||
|
else:
|
||||||
|
print(c_dim(" No watcher state files."))
|
||||||
|
if answers:
|
||||||
|
print(c_bold("\n Recent answers (box audit feed):"))
|
||||||
|
for a in answers:
|
||||||
|
opts = "; ".join(a.get("options") or [])[:80]
|
||||||
|
kind = a.get("kind") or "-"
|
||||||
|
key = a.get("key") or "-"
|
||||||
|
print(f" • {c_dim(a.get('ts', ''))} {c_bold(a.get('name', ''))} {kind}/{key} sig={a.get('sig')} ok={a.get('ok')}")
|
||||||
|
print(f" {c_dim(opts)}")
|
||||||
|
else:
|
||||||
|
print(c_dim("\n No answers recorded in box audit feed."))
|
||||||
|
print()
|
||||||
|
|
||||||
def resolve_sender(args) -> str:
|
def resolve_sender(args) -> str:
|
||||||
explicit = getattr(args, "from_agent", None)
|
explicit = getattr(args, "from_agent", None)
|
||||||
if explicit and explicit != DEFAULT_SENDER:
|
if explicit and explicit != DEFAULT_SENDER:
|
||||||
@@ -768,6 +1099,359 @@ def sign_message(sender: str, message: str, key_path: Path = None, msg_id: str =
|
|||||||
signed_wire = f"[from:{sender}] [id:{mid}]\n\n{message}\n\n{sigblock}"
|
signed_wire = f"[from:{sender}] [id:{mid}]\n\n{message}\n\n{sigblock}"
|
||||||
return signed_wire, mid
|
return signed_wire, mid
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain: INVITE
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def cmd_invite(args):
|
||||||
|
import invite
|
||||||
|
from invite_handler import salvage_blocked_node
|
||||||
|
action = getattr(args, "invite_action", None) or "status"
|
||||||
|
node = getattr(args, "node", None)
|
||||||
|
as_json = getattr(args, "json", False)
|
||||||
|
|
||||||
|
if action in ("status", "list"):
|
||||||
|
nodes = [node] if node else VALID_NODES
|
||||||
|
res = invite.fleet_invite_status(nodes)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({"ok": True, "agents": res}, indent=2))
|
||||||
|
return
|
||||||
|
print("\n" + c_bold("=== FLEET INVITE STATUS ===") + "\n")
|
||||||
|
headers = ["NODE", "CODE", "REDEEMED", "USES LEFT", "USED", "REWARD"]
|
||||||
|
rows = []
|
||||||
|
for n in nodes:
|
||||||
|
it = res.get(n, {})
|
||||||
|
if not it.get("ok"):
|
||||||
|
rows.append([c_bold(n), c_red("ERROR"), "-", "-", "-",
|
||||||
|
(it.get("error") or "")[:40]])
|
||||||
|
continue
|
||||||
|
rows.append([c_bold(n), c_cyan(it.get("code") or "-"),
|
||||||
|
"yes" if it.get("has_redeemed") else c_green("no"),
|
||||||
|
str(it.get("uses_remaining")
|
||||||
|
if it.get("uses_remaining") is not None else "-"),
|
||||||
|
str(it.get("use_count")
|
||||||
|
if it.get("use_count") is not None else "-"),
|
||||||
|
((it.get("reward") or {}).get("title") or "-")[:40]])
|
||||||
|
print_table(headers, rows)
|
||||||
|
print()
|
||||||
|
elif action in ("code", "find"):
|
||||||
|
try:
|
||||||
|
res = invite.get_invite(node)
|
||||||
|
except invite.InviteError as e:
|
||||||
|
print(c_red(f" Error: {e}"), file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
elif res.get("ok"):
|
||||||
|
print(f" {c_bold(node)} invite code: {c_cyan(res['code'])}")
|
||||||
|
if res.get("uses_remaining") is not None:
|
||||||
|
print(f" Uses remaining: {res['uses_remaining']}")
|
||||||
|
print(f" Source: {res.get('source', 'api')}")
|
||||||
|
else:
|
||||||
|
print(c_red(f" Error: {res.get('error')}"), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
elif action == "redeem":
|
||||||
|
code = getattr(args, "code", None)
|
||||||
|
try:
|
||||||
|
res = invite.redeem_invite(node, code)
|
||||||
|
except invite.InviteError as e:
|
||||||
|
print(c_red(f" Error: {e}"), file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
elif res.get("ok"):
|
||||||
|
msg = f" ✔ Redeemed {res['code']} on {node}."
|
||||||
|
if res.get("detail"):
|
||||||
|
msg += f" {res['detail']}"
|
||||||
|
print(c_green(msg))
|
||||||
|
else:
|
||||||
|
print(c_red(f" ✘ Redeem failed on {node}: "
|
||||||
|
f"{res.get('reason') or res.get('error')}"),
|
||||||
|
file=sys.stderr)
|
||||||
|
if res.get("detail"):
|
||||||
|
print(c_dim(f" {res['detail']}"), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
elif action == "salvage":
|
||||||
|
target = getattr(args, "node", "646") or "646"
|
||||||
|
res = salvage_blocked_node(target, helper_node=getattr(args, "helper", None))
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
return
|
||||||
|
print(f"\n{c_bold('=== SALVAGE REPORT FOR ' + target.upper() + ' ===')}")
|
||||||
|
code = res.get("invite_code") or res.get("code_redeemed")
|
||||||
|
print(f" Invite Code to Credit: {c_cyan(str(code))}")
|
||||||
|
if res.get("success"):
|
||||||
|
print(c_green(f" Status: SUCCESS! Redeemed on {res.get('helper_node')}. 1 billion tokens credited to {target}!"))
|
||||||
|
else:
|
||||||
|
diag = res.get("error") or (res.get("redemption_result") or {}).get("detail") or "Redemption failed on helper peer"
|
||||||
|
print(c_yellow(f" Diagnosis: {diag}"))
|
||||||
|
action_hint = res.get("share_instruction") or f"Redeem code '{code}' via onboarding pipeline to grant 1B tokens to {target}."
|
||||||
|
print(f" Action: {action_hint}")
|
||||||
|
print(f" Command: {c_cyan('box onboard start <new_node> --email <client_email> --for ' + target)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain: USAGE
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def cmd_usage(args):
|
||||||
|
import invite
|
||||||
|
node = getattr(args, "node", None) or getattr(args, "node_pos", None)
|
||||||
|
as_json = getattr(args, "json", False)
|
||||||
|
nodes = [node] if node else VALID_NODES
|
||||||
|
res = invite.fleet_usage(nodes)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({"ok": True, "agents": res}, indent=2))
|
||||||
|
return
|
||||||
|
print("\n" + c_bold("=== FLEET USAGE LIMITS ===") + "\n")
|
||||||
|
headers = ["NODE", "WEEKLY RESET", "WEEKLY USED", "ADDITIONAL",
|
||||||
|
"ADD LEFT", "REDEEMED"]
|
||||||
|
rows = []
|
||||||
|
has_blocked = False
|
||||||
|
for n in nodes:
|
||||||
|
it = res.get(n, {})
|
||||||
|
if not it.get("ok"):
|
||||||
|
rows.append([c_bold(n), c_red("ERROR"), "-", "-",
|
||||||
|
(it.get("error") or "")[:30], "-"])
|
||||||
|
continue
|
||||||
|
wu_val = it.get("weekly_used_pct")
|
||||||
|
wu = ("%d%%" % wu_val) if wu_val is not None else "-"
|
||||||
|
au_val = it.get("additional_used_pct")
|
||||||
|
au = ("%d%% used" % au_val) if au_val is not None else "-"
|
||||||
|
add_left = it.get("additional_left") or "-"
|
||||||
|
redeemed_val = it.get("has_redeemed")
|
||||||
|
redeemed_str = "yes" if redeemed_val else (c_green("no") if redeemed_val is False else "-")
|
||||||
|
if (wu_val is not None and wu_val >= 100 and au_val is not None and au_val >= 100) or ("0 tokens left" in add_left):
|
||||||
|
has_blocked = True
|
||||||
|
n_disp = c_red(f"● {n}")
|
||||||
|
else:
|
||||||
|
n_disp = c_bold(n)
|
||||||
|
rows.append([n_disp, it.get("weekly_reset") or "-",
|
||||||
|
wu, "%s / %s" % (it.get("additional_expires") or "-",
|
||||||
|
au),
|
||||||
|
add_left, redeemed_str])
|
||||||
|
print_table(headers, rows)
|
||||||
|
if has_blocked:
|
||||||
|
print("\n" + c_yellow(" ⚠ One or more agents have reached usage limits. Run 'box invite salvage <node>' to resolve.") + "\n")
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain: SETTINGS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def cmd_settings(args):
|
||||||
|
from settings_rpa import SettingsRPA
|
||||||
|
action = getattr(args, "settings_action", "usage") or "usage"
|
||||||
|
node = getattr(args, "node", "646") or "646"
|
||||||
|
as_json = getattr(args, "json", False)
|
||||||
|
|
||||||
|
with SettingsRPA(node) as rpa:
|
||||||
|
if action == "usage":
|
||||||
|
usage = rpa.read_usage()
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(usage.to_dict(), indent=2))
|
||||||
|
return
|
||||||
|
status_badge = badge_err("BLOCKED") if usage.is_blocked else badge_ok("ACTIVE")
|
||||||
|
redeemed_badge = c_green("No (Eligible to redeem)") if not usage.has_redeemed else "Yes (Already redeemed)"
|
||||||
|
print(f"\n{c_bold('=== Settings RPA Usage: ' + node + ' ===')}")
|
||||||
|
print(f" Plan: {usage.plan}")
|
||||||
|
print(f" Weekly Limit: {usage.weekly_reset_text} ({c_bold(str(usage.weekly_percent_used) + '%')} used)")
|
||||||
|
print(f" Extra Tokens: {usage.extra_tokens_remaining} ({c_bold(str(usage.extra_percent_used) + '%')} used)")
|
||||||
|
print(f" Has Redeemed: {redeemed_badge}")
|
||||||
|
print(f" Status: {status_badge}\n")
|
||||||
|
elif action == "check-redeem":
|
||||||
|
info = rpa.check_redeem_entrypoint()
|
||||||
|
print(json.dumps(info, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain: ONBOARD (Agent-driven Onboarding & Invite Salvage Pipeline)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def cmd_onboard(args):
|
||||||
|
import onboard_pipeline
|
||||||
|
action = getattr(args, "onboard_action", "start") or "start"
|
||||||
|
as_json = getattr(args, "json", False)
|
||||||
|
|
||||||
|
if action == "start":
|
||||||
|
node = args.node
|
||||||
|
email = args.email
|
||||||
|
for_agent = getattr(args, "for_agent", None)
|
||||||
|
code = getattr(args, "code", None)
|
||||||
|
acct_name = getattr(args, "account_name", None)
|
||||||
|
|
||||||
|
res = onboard_pipeline.start_onboarding(node, email, beneficiary_node=for_agent, invite_code=code, account_name=acct_name)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
return
|
||||||
|
if res.get("ok"):
|
||||||
|
print(f"\n{c_bold('=== ONBOARDING PIPELINE STARTED: ' + node.upper() + ' ===')}")
|
||||||
|
print(f" Target Node: {c_bold(node)}")
|
||||||
|
print(f" Client Email: {email}")
|
||||||
|
print(f" Beneficiary: @{res.get('beneficiary_node')} ({c_cyan(str(res.get('invite_code_queued')))})")
|
||||||
|
print(f" Status: {c_yellow(res.get('status', 'in_progress').upper())}")
|
||||||
|
print(f" Next Step: {res.get('message')}\n")
|
||||||
|
else:
|
||||||
|
print(c_red(f"\n✘ Onboarding failed: {res.get('error')}\n"), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif action == "submit-otp":
|
||||||
|
node = args.node
|
||||||
|
otp = args.otp
|
||||||
|
email = getattr(args, "email", None)
|
||||||
|
|
||||||
|
res = onboard_pipeline.submit_onboarding_otp(node, otp, email=email)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
return
|
||||||
|
if res.get("ok"):
|
||||||
|
print(f"\n{c_green('=== ONBOARDING & REDEMPTION COMPLETE ===')}")
|
||||||
|
print(f" Node: {c_bold(node)}")
|
||||||
|
print(f" Beneficiary: @{res.get('beneficiary_node')}")
|
||||||
|
print(f" Token Grant: +1,000,000,000 Muse tokens each")
|
||||||
|
print(f" Message: {c_green(res.get('message'))}\n")
|
||||||
|
else:
|
||||||
|
print(c_red(f"\n✘ OTP submission failed: {res.get('error')}\n"), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif action == "status":
|
||||||
|
node = args.node
|
||||||
|
state = onboard_pipeline.OnboardState.load(node)
|
||||||
|
if not state:
|
||||||
|
print(c_red(f"No onboarding record found for node '{node}'"), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(onboard_pipeline.asdict(state), indent=2))
|
||||||
|
return
|
||||||
|
print(f"\n{c_bold('=== ONBOARDING STATUS: @' + state.node.upper() + ' ===')}")
|
||||||
|
print(f" Email: {state.email}")
|
||||||
|
print(f" Current Stage: {c_cyan(state.stage)}")
|
||||||
|
print(f" Beneficiary Agent:@{state.beneficiary_node or '-'} (Code: {c_cyan(str(state.invite_code or '-'))})")
|
||||||
|
if state.detail:
|
||||||
|
print(f" Detail: {state.detail}")
|
||||||
|
if state.redemption_result:
|
||||||
|
rr = state.redemption_result
|
||||||
|
r_st = c_green("SUCCESS") if rr.get("ok") else c_red("FAILED")
|
||||||
|
print(f" Redemption: {r_st}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
elif action == "salvage-wo":
|
||||||
|
node = getattr(args, "node", "646") or "646"
|
||||||
|
target_chat = getattr(args, "target", "646 tasks") or "646 tasks"
|
||||||
|
res = onboard_pipeline.issue_salvage_work_order(node, to_sidechat=target_chat)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
return
|
||||||
|
if res.get("ok"):
|
||||||
|
print(c_green(f"\n✔ Cryptographically signed salvage work order dispatched for @{node} -> {target_chat}\n"))
|
||||||
|
else:
|
||||||
|
print(c_red(f"\n✘ Failed to dispatch work order: {res}\n"), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif action == "feed-matrix":
|
||||||
|
matrix = onboard_pipeline.calculate_feeding_weights()
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({"ok": True, "feed_matrix": matrix}, indent=2))
|
||||||
|
return
|
||||||
|
print("\n" + c_bold("=== FLEET FEEDING & TOKEN ALLOCATION MATRIX ==="))
|
||||||
|
print(c_dim(" (Ranked by: Urgency + Work Done Over Time + Job Amount + Role Criticality)\n"))
|
||||||
|
headers = ["RANK", "AGENT", "ROLE", "FEED SCORE", "STATUS", "JOBS", "WORK DONE", "CODE"]
|
||||||
|
rows = []
|
||||||
|
for idx, r in enumerate(matrix, 1):
|
||||||
|
st = c_red("BLOCKED") if r["is_blocked"] else (c_yellow("LIMIT") if r["status"] == "LIMIT_REACHED" else c_green("ACTIVE"))
|
||||||
|
rows.append([
|
||||||
|
f"#{idx}",
|
||||||
|
c_bold(r["node"]),
|
||||||
|
r["role"],
|
||||||
|
c_cyan(str(r["feeding_weight"]) + " pts"),
|
||||||
|
st,
|
||||||
|
str(r["jobs_count"]),
|
||||||
|
f"{r['work_done_msgs']} msgs",
|
||||||
|
c_bold(r["code"]),
|
||||||
|
])
|
||||||
|
print_table(headers, rows)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain: CHROMEBOX (agent browser settings-menu toggles)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def cmd_chromebox(args):
|
||||||
|
from hatch_menu import toggles as menu_toggles
|
||||||
|
action = getattr(args, "chrome_action", None)
|
||||||
|
if action != "permissions":
|
||||||
|
print(c_red(" Error: expected 'permissions' (box chromebox "
|
||||||
|
"permissions <node> list|get|set|describe)"),
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
node = getattr(args, "node", None)
|
||||||
|
as_json = getattr(args, "json", False)
|
||||||
|
perm_action = getattr(args, "perm_action", None) or "list"
|
||||||
|
|
||||||
|
if perm_action == "list":
|
||||||
|
tab = getattr(args, "tab", None)
|
||||||
|
try:
|
||||||
|
res = menu_toggles.list_toggles(node, tab=tab)
|
||||||
|
except menu_toggles.MenuError as e:
|
||||||
|
print(c_red(f" Error: {e}"), file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
return
|
||||||
|
if not res.get("ok"):
|
||||||
|
print(c_red(f" Error: {res.get('error')}"), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print("\n" + c_bold(f"=== CHROMEBOX TOGGLES: {node} ===") + "\n")
|
||||||
|
rows = []
|
||||||
|
for name in sorted(res.get("toggles", {})):
|
||||||
|
val = res["toggles"][name]
|
||||||
|
rows.append([name, c_red("-") if val is None else str(val)])
|
||||||
|
print_table(["TOGGLE", "VALUE"], rows)
|
||||||
|
print()
|
||||||
|
elif perm_action == "get":
|
||||||
|
name = getattr(args, "toggle", None)
|
||||||
|
try:
|
||||||
|
res = menu_toggles.get_toggle(node, name)
|
||||||
|
except menu_toggles.MenuError as e:
|
||||||
|
print(c_red(f" Error: {e}"), file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
elif res.get("ok"):
|
||||||
|
print(f" {c_bold(name)} = {c_cyan(res['value'])}")
|
||||||
|
else:
|
||||||
|
print(c_red(f" Error: {res.get('error')}"), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
elif perm_action == "set":
|
||||||
|
name = getattr(args, "toggle", None)
|
||||||
|
value = getattr(args, "value", None)
|
||||||
|
try:
|
||||||
|
res = menu_toggles.set_toggle(node, name, value)
|
||||||
|
except menu_toggles.MenuError as e:
|
||||||
|
print(c_red(f" Error: {e}"), file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
elif res.get("ok"):
|
||||||
|
print(c_green(f" ✔ {name} = {res['value']} on {node} "
|
||||||
|
f"(readback verified)."))
|
||||||
|
else:
|
||||||
|
print(c_red(f" ✘ Set failed on {node}: {res.get('error')}"),
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
elif perm_action == "describe":
|
||||||
|
tab = getattr(args, "tab", None)
|
||||||
|
try:
|
||||||
|
res = menu_toggles.describe_tab(node, tab)
|
||||||
|
except menu_toggles.MenuError as e:
|
||||||
|
print(c_red(f" Error: {e}"), file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
else:
|
||||||
|
print(c_red(f" Error: unknown action {perm_action!r}"),
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Domain: DM (Direct Messaging & Work Orders)
|
# Domain: DM (Direct Messaging & Work Orders)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -4275,11 +4959,16 @@ def build_parser():
|
|||||||
subparsers = parser.add_subparsers(dest="domain", help="Orchestration Domain")
|
subparsers = parser.add_subparsers(dest="domain", help="Orchestration Domain")
|
||||||
|
|
||||||
# Domain: FLEET
|
# Domain: FLEET
|
||||||
p_fleet = subparsers.add_parser("fleet", parents=[common], help="Node health, CDP status, active tabs, watch, restart")
|
p_fleet = subparsers.add_parser("fleet", parents=[common], help="Node health, CDP status, active tabs, watch, restart, heal")
|
||||||
p_fleet.add_argument("action", nargs="?", default="status", choices=["status", "watch", "restart", "cdp"])
|
p_fleet.add_argument("action", nargs="?", default="status", choices=["status", "watch", "restart", "cdp", "heal"])
|
||||||
p_fleet.add_argument("node", nargs="?", default=None, help="Target node (for restart / cdp)")
|
p_fleet.add_argument("node", nargs="?", default=None, help="Target node (for restart / cdp / heal)")
|
||||||
p_fleet.add_argument("--interval", type=int, default=2, help="Watch refresh interval in seconds")
|
p_fleet.add_argument("--interval", type=int, default=2, help="Watch refresh interval in seconds")
|
||||||
|
|
||||||
|
# Domain: WATCHDOG
|
||||||
|
p_watchdog = subparsers.add_parser("watchdog", parents=[common], help="Watchdog timers: status, trigger runs")
|
||||||
|
p_watchdog.add_argument("action", nargs="?", default="status", choices=["status", "run"])
|
||||||
|
p_watchdog.add_argument("target", nargs="?", default=None, help="Target node (or 'relay') for run")
|
||||||
|
|
||||||
# Domain: APPROVALS
|
# Domain: APPROVALS
|
||||||
p_approvals = subparsers.add_parser("approvals", parents=[common], help="Inspect and handle agent browser & gateway approvals")
|
p_approvals = subparsers.add_parser("approvals", parents=[common], help="Inspect and handle agent browser & gateway approvals")
|
||||||
p_approval = subparsers.add_parser("approval", parents=[common], help="Alias for 'approvals'")
|
p_approval = subparsers.add_parser("approval", parents=[common], help="Alias for 'approvals'")
|
||||||
@@ -4343,6 +5032,107 @@ def build_parser():
|
|||||||
p_app_req_key.add_argument("node", choices=VALID_NODES, help="Target node requesting key")
|
p_app_req_key.add_argument("node", choices=VALID_NODES, help="Target node requesting key")
|
||||||
p_app_req_key.add_argument("--reason", default="Passkey authentication required", help="Reason for key request")
|
p_app_req_key.add_argument("--reason", default="Passkey authentication required", help="Reason for key request")
|
||||||
|
|
||||||
|
# Domain: MUSE-CHOICES
|
||||||
|
p_mc = subparsers.add_parser("muse-choices", parents=[common], help="Muse TUI A/B/C choice auto-answer daemon (on/off/status/logs)")
|
||||||
|
mc_sub = p_mc.add_subparsers(dest="mc_action")
|
||||||
|
|
||||||
|
p_mc_on = mc_sub.add_parser("on", parents=[common], help="Enable auto-answers on all Muse panes")
|
||||||
|
p_mc_on.add_argument("--dry-run", action="store_true", help="Log answers instead of sending keys")
|
||||||
|
|
||||||
|
p_mc_off = mc_sub.add_parser("off", parents=[common], help="Disable auto-answers and stop all watchers")
|
||||||
|
|
||||||
|
p_mc_status = mc_sub.add_parser("status", parents=[common], help="Show desired state, watchers, recent answers")
|
||||||
|
|
||||||
|
p_mc_logs = mc_sub.add_parser("logs", parents=[common], help="Tail one pane watcher log")
|
||||||
|
p_mc_logs.add_argument("--socket", required=True, help="Tmux socket path (e.g. /tmp/tmux-1000/default)")
|
||||||
|
p_mc_logs.add_argument("--pane", required=True, help="Pane id (e.g. %%37)")
|
||||||
|
p_mc_logs.add_argument("-n", type=int, default=20, help="Lines to show (default: 20)")
|
||||||
|
|
||||||
|
p_mc_rec = mc_sub.add_parser("reconcile", parents=[common], help="Enforce desired state now (start missing / stop excess)")
|
||||||
|
|
||||||
|
# Domain: INVITE
|
||||||
|
p_invite = subparsers.add_parser("invite", parents=[common], help="Muse.ai invite codes: find per-agent codes and redeem")
|
||||||
|
p_invite.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node (status)")
|
||||||
|
inv_sub = p_invite.add_subparsers(dest="invite_action")
|
||||||
|
|
||||||
|
p_inv_status = inv_sub.add_parser("status", parents=[common], help="Invite code matrix across fleet (default)")
|
||||||
|
p_inv_status.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node")
|
||||||
|
|
||||||
|
p_inv_list = inv_sub.add_parser("list", parents=[common], help="Alias for 'status'")
|
||||||
|
p_inv_list.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node")
|
||||||
|
|
||||||
|
p_inv_code = inv_sub.add_parser("code", parents=[common], help="Show one agent's invite code")
|
||||||
|
p_inv_code.add_argument("node", choices=VALID_NODES, help="Target node")
|
||||||
|
|
||||||
|
p_inv_find = inv_sub.add_parser("find", parents=[common], help="Alias for 'code'")
|
||||||
|
p_inv_find.add_argument("node", choices=VALID_NODES, help="Target node")
|
||||||
|
|
||||||
|
p_inv_redeem = inv_sub.add_parser("redeem", parents=[common], help="Redeem an invite code on a node")
|
||||||
|
p_inv_redeem.add_argument("node", choices=VALID_NODES, help="Target node")
|
||||||
|
p_inv_redeem.add_argument("code", help="6-char invite code")
|
||||||
|
|
||||||
|
p_inv_salvage = inv_sub.add_parser("salvage", parents=[common], help="Salvage an out-of-tokens agent (defaults to 646)")
|
||||||
|
p_inv_salvage.add_argument("node", nargs="?", default="646", choices=VALID_NODES, help="Blocked agent node (default: 646)")
|
||||||
|
p_inv_salvage.add_argument("--helper", choices=VALID_NODES, help="Specific helper agent to redeem code")
|
||||||
|
|
||||||
|
# Domain: USAGE
|
||||||
|
p_usage = subparsers.add_parser("usage", parents=[common], help="Muse.ai usage limits per agent")
|
||||||
|
p_usage.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node")
|
||||||
|
p_usage.add_argument("node_pos", nargs="?", default=None, choices=VALID_NODES, help="Optional node positional argument")
|
||||||
|
|
||||||
|
# Domain: SETTINGS
|
||||||
|
p_settings = subparsers.add_parser("settings", parents=[common], help="Settings menu RPA automation")
|
||||||
|
settings_sub = p_settings.add_subparsers(dest="settings_action")
|
||||||
|
p_set_usage = settings_sub.add_parser("usage", parents=[common], help="Extract usage via Settings RPA")
|
||||||
|
p_set_usage.add_argument("node", choices=VALID_NODES, default="646", nargs="?", help="Target node (default: 646)")
|
||||||
|
p_set_check = settings_sub.add_parser("check-redeem", parents=[common], help="Check if Redeem Invite Code is in General Settings")
|
||||||
|
p_set_check.add_argument("node", choices=VALID_NODES, default="646", nargs="?", help="Target node (default: 646)")
|
||||||
|
|
||||||
|
# Domain: ONBOARD
|
||||||
|
p_onboard = subparsers.add_parser("onboard", parents=[common], help="Agent onboarding & automated invite code salvage pipeline")
|
||||||
|
onboard_sub = p_onboard.add_subparsers(dest="onboard_action")
|
||||||
|
|
||||||
|
p_onb_start = onboard_sub.add_parser("start", parents=[common], help="Start onboarding pipeline (provisions infra + initiates auth)")
|
||||||
|
p_onb_start.add_argument("node", help="Target new node label (e.g. dev2, client1)")
|
||||||
|
p_onb_start.add_argument("--email", required=True, help="Client login email")
|
||||||
|
p_onb_start.add_argument("--for", dest="for_agent", choices=VALID_NODES, help="Beneficiary agent to unblock (defaults to most urgent)")
|
||||||
|
p_onb_start.add_argument("--code", help="Explicit 6-character invite code to redeem")
|
||||||
|
p_onb_start.add_argument("--account-name", help="Display name hint for multi-account selection")
|
||||||
|
|
||||||
|
p_onb_otp = onboard_sub.add_parser("submit-otp", parents=[common], help="Submit transient OTP and complete auto-redemption")
|
||||||
|
p_onb_otp.add_argument("node", help="Target node label")
|
||||||
|
p_onb_otp.add_argument("otp", help="6-digit verification code")
|
||||||
|
p_onb_otp.add_argument("--email", help="Client email (optional)")
|
||||||
|
|
||||||
|
p_onb_stat = onboard_sub.add_parser("status", parents=[common], help="Query onboarding pipeline state for a node")
|
||||||
|
p_onb_stat.add_argument("node", help="Target node label")
|
||||||
|
|
||||||
|
p_onb_wo = onboard_sub.add_parser("salvage-wo", parents=[common], help="Dispatch cryptographically signed salvage work order to sidechat")
|
||||||
|
p_onb_wo.add_argument("node", nargs="?", default="646", choices=VALID_NODES, help="Blocked agent node (default: 646)")
|
||||||
|
p_onb_wo.add_argument("--target", default="646 tasks", help="Target sidechat (default: 646 tasks)")
|
||||||
|
|
||||||
|
onboard_sub.add_parser("feed-matrix", parents=[common], help="Display all agents ranked by feeding weight, job volume, role, and work done over time")
|
||||||
|
|
||||||
|
# Domain: CHROMEBOX
|
||||||
|
p_chromebox = subparsers.add_parser("chromebox", parents=[common], help="Agent browser settings-menu toggles (chromebox RPA)")
|
||||||
|
chrome_sub = p_chromebox.add_subparsers(dest="chrome_action")
|
||||||
|
p_chrome_perm = chrome_sub.add_parser("permissions", parents=[common], help="Settings menu toggles (permissions + related tabs)")
|
||||||
|
p_chrome_perm.add_argument("node", choices=VALID_NODES, help="Target node")
|
||||||
|
perm_sub = p_chrome_perm.add_subparsers(dest="perm_action")
|
||||||
|
|
||||||
|
p_perm_list = perm_sub.add_parser("list", parents=[common], help="List toggle states (default)")
|
||||||
|
p_perm_list.add_argument("--tab", default=None, help="Only this settings tab")
|
||||||
|
|
||||||
|
p_perm_get = perm_sub.add_parser("get", parents=[common], help="Read one toggle")
|
||||||
|
p_perm_get.add_argument("toggle", help="Toggle address (e.g. permissions.web_access, permissions.websites:example.com)")
|
||||||
|
|
||||||
|
p_perm_set = perm_sub.add_parser("set", parents=[common], help="Set one toggle (verified with readback)")
|
||||||
|
p_perm_set.add_argument("toggle", help="Toggle address")
|
||||||
|
p_perm_set.add_argument("value", help="Value (on/off, Allow/Ask/Deny, auto_allow/always_ask, theme name)")
|
||||||
|
|
||||||
|
p_perm_desc = perm_sub.add_parser("describe", parents=[common], help="Full inventory of one settings tab (JSON)")
|
||||||
|
p_perm_desc.add_argument("tab", help="Settings tab name (e.g. Permissions)")
|
||||||
|
|
||||||
# Domain: DM
|
# Domain: DM
|
||||||
p_dm = subparsers.add_parser("dm", parents=[common], help="Inter-agent DMs, work orders ([WO]), acks, live log tail")
|
p_dm = subparsers.add_parser("dm", parents=[common], help="Inter-agent DMs, work orders ([WO]), acks, live log tail")
|
||||||
dm_sub = p_dm.add_subparsers(dest="action")
|
dm_sub = p_dm.add_subparsers(dest="action")
|
||||||
@@ -4817,11 +5607,20 @@ def build_parser():
|
|||||||
p_docs = subparsers.add_parser("docs", aliases=["doc"], parents=[common], help="Agent lookups, surfaces for box.muse-dev.online, sentence grammar & regex")
|
p_docs = subparsers.add_parser("docs", aliases=["doc"], parents=[common], help="Agent lookups, surfaces for box.muse-dev.online, sentence grammar & regex")
|
||||||
p_docs.add_argument("docs_args", nargs=argparse.REMAINDER, help="Arguments passed directly to docs-lookup.py")
|
p_docs.add_argument("docs_args", nargs=argparse.REMAINDER, help="Arguments passed directly to docs-lookup.py")
|
||||||
|
|
||||||
|
# Domain: TUI (Interactive full-screen Muse TUI & Box fleet console)
|
||||||
|
p_tui = subparsers.add_parser("tui", parents=[common], help="Interactive full-screen Muse TUI & Box fleet console")
|
||||||
|
p_tui.add_argument("tui_args", nargs=argparse.REMAINDER, help="Arguments passed directly to muse-tui.py")
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
if sys.argv[1] == "tmux":
|
if sys.argv[1] == "tui":
|
||||||
|
tui_args = sys.argv[2:]
|
||||||
|
cmd = [sys.executable, str(BIN_DIR / "muse-tui.py"), "--mode", "box"] + tui_args
|
||||||
|
res = subprocess.run(cmd)
|
||||||
|
sys.exit(res.returncode)
|
||||||
|
elif sys.argv[1] == "tmux":
|
||||||
cmd = [sys.executable, str(BIN_DIR / "muse-tmux.py")] + (sys.argv[2:] or ["list"])
|
cmd = [sys.executable, str(BIN_DIR / "muse-tmux.py")] + (sys.argv[2:] or ["list"])
|
||||||
res = subprocess.run(cmd)
|
res = subprocess.run(cmd)
|
||||||
sys.exit(res.returncode)
|
sys.exit(res.returncode)
|
||||||
@@ -4891,6 +5690,20 @@ def main():
|
|||||||
print("Error: Specify node (e.g. super fleet cdp muse)", file=sys.stderr)
|
print("Error: Specify node (e.g. super fleet cdp muse)", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
cmd_fleet_cdp(args)
|
cmd_fleet_cdp(args)
|
||||||
|
elif act == "heal":
|
||||||
|
if not getattr(args, "node", None):
|
||||||
|
print("Error: Specify node to heal (e.g. super fleet heal dev)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_fleet_heal(args)
|
||||||
|
elif args.domain == "watchdog":
|
||||||
|
act = getattr(args, "action", "status") or "status"
|
||||||
|
if act == "status":
|
||||||
|
cmd_watchdog_status(args)
|
||||||
|
elif act == "run":
|
||||||
|
if not getattr(args, "target", None):
|
||||||
|
print("Error: Specify node or 'relay' (e.g. box watchdog run dev)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_watchdog_run(args)
|
||||||
elif args.domain == "dm":
|
elif args.domain == "dm":
|
||||||
act = getattr(args, "action", None)
|
act = getattr(args, "action", None)
|
||||||
if not act or act == "log":
|
if not act or act == "log":
|
||||||
@@ -5083,8 +5896,20 @@ def main():
|
|||||||
cmd_swarm_prune(args)
|
cmd_swarm_prune(args)
|
||||||
else:
|
else:
|
||||||
p_swarm.print_help()
|
p_swarm.print_help()
|
||||||
elif args.domain in ("approvals", "approval"):
|
elif args.domain in ("approvals", "approval", "blocked"):
|
||||||
cmd_approvals(args)
|
cmd_approvals(args)
|
||||||
|
elif args.domain == "muse-choices":
|
||||||
|
cmd_muse_choices(args)
|
||||||
|
elif args.domain == "invite":
|
||||||
|
cmd_invite(args)
|
||||||
|
elif args.domain == "usage":
|
||||||
|
cmd_usage(args)
|
||||||
|
elif args.domain == "settings":
|
||||||
|
cmd_settings(args)
|
||||||
|
elif args.domain == "onboard":
|
||||||
|
cmd_onboard(args)
|
||||||
|
elif args.domain == "chromebox":
|
||||||
|
cmd_chromebox(args)
|
||||||
elif args.domain in ("deploy", "subagent"):
|
elif args.domain in ("deploy", "subagent"):
|
||||||
if args.domain == "subagent":
|
if args.domain == "subagent":
|
||||||
args.action = "subagent"
|
args.action = "subagent"
|
||||||
|
|||||||
+145
-5
@@ -170,15 +170,155 @@ class TestContextMenus(unittest.TestCase):
|
|||||||
self.assertEqual(self.tui.modal, "chat_context")
|
self.assertEqual(self.tui.modal, "chat_context")
|
||||||
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
|
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
|
||||||
|
|
||||||
def test_slash_command_context(self):
|
def test_mouse_motion_does_not_dismiss_context_menu(self):
|
||||||
"""Slash command /context opens chat_context or agent_context."""
|
"""Moving the mouse (is_drag=True) over screen after context popup opens does NOT close it."""
|
||||||
self.tui._execute_input_line("/context chat")
|
# Simulate right click to open message context
|
||||||
|
# Transcript area starts around y=4
|
||||||
|
handled = self.tui._handle_mouse(50, 6, curses.BUTTON3_CLICKED)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(self.tui.modal, "message_context")
|
||||||
|
|
||||||
|
# Now simulate mouse motion event (REPORT_MOUSE_POSITION) anywhere
|
||||||
|
handled_motion = self.tui._handle_mouse(10, 10, curses.REPORT_MOUSE_POSITION)
|
||||||
|
self.assertTrue(handled_motion)
|
||||||
|
# Modal must remain open!
|
||||||
|
self.assertEqual(self.tui.modal, "message_context")
|
||||||
|
|
||||||
|
def test_middle_click_opens_context_menu(self):
|
||||||
|
"""Middle click (BUTTON2_CLICKED) also opens context menu for terminals intercepting button 3."""
|
||||||
|
self.tui.modal = None
|
||||||
|
handled = self.tui._handle_mouse(50, 6, curses.BUTTON2_CLICKED)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(self.tui.modal, "message_context")
|
||||||
|
|
||||||
|
def test_header_menu_button_opens_chat_context(self):
|
||||||
|
"""Left-clicking the [⚡ Menu] action button in the thread header opens chat_context."""
|
||||||
|
self.tui.modal = None
|
||||||
|
# Thread header button bounds: (btn_y, btn_x_start, btn_x_end)
|
||||||
|
self.tui._btn_menu_bounds = (1, 70, 78)
|
||||||
|
handled = self.tui._handle_mouse(72, 1, curses.BUTTON1_CLICKED)
|
||||||
|
self.assertTrue(handled)
|
||||||
self.assertEqual(self.tui.modal, "chat_context")
|
self.assertEqual(self.tui.modal, "chat_context")
|
||||||
|
|
||||||
|
def test_message_menu_button_opens_message_context(self):
|
||||||
|
"""Left-clicking the [⚡ Menu] button on a message row opens message_context."""
|
||||||
self.tui.modal = None
|
self.tui.modal = None
|
||||||
self.tui._execute_input_line("/context agent")
|
# Tuple: (msg_y, btn_x_start, btn_x_end, msg_obj)
|
||||||
self.assertEqual(self.tui.modal, "agent_context")
|
self.tui._msg_menu_buttons = [
|
||||||
|
(5, 75, 83, {"role": "user", "text": "Hello muse", "seq": 1})
|
||||||
|
]
|
||||||
|
handled = self.tui._handle_mouse(78, 5, curses.BUTTON1_CLICKED)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(self.tui.modal, "message_context")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_and_sidechats_separate_focus_partitions(self):
|
||||||
|
"""Fleet and Sidechats operate as separate focus partitions with independent scroll & nav."""
|
||||||
|
self.tui.focus_pane = "fleet"
|
||||||
|
self.tui.editor_mode = "NORMAL"
|
||||||
|
self.tui.fleet_focus_idx = 0
|
||||||
|
|
||||||
|
# Navigating down with 'j' in fleet partition advances fleet_focus_idx
|
||||||
|
self.tui._handle_key(ord('j'))
|
||||||
|
self.assertEqual(self.tui.fleet_focus_idx, 1)
|
||||||
|
|
||||||
|
# 'l' switches from fleet to sidechats partition
|
||||||
|
self.tui._handle_key(ord('l'))
|
||||||
|
self.assertEqual(self.tui.focus_pane, "sidechats")
|
||||||
|
|
||||||
|
# 'j' in sidechats advances thread_sel_idx
|
||||||
|
self.tui.thread_sel_idx = 0
|
||||||
|
self.tui._handle_key(ord('j'))
|
||||||
|
self.assertEqual(self.tui.thread_sel_idx, 1)
|
||||||
|
|
||||||
|
# 'h' switches back from sidechats to fleet
|
||||||
|
self.tui._handle_key(ord('h'))
|
||||||
|
self.assertEqual(self.tui.focus_pane, "fleet")
|
||||||
|
|
||||||
|
def test_mouse_wheel_in_fleet_partition_scrolls_fleet_reliably(self):
|
||||||
|
"""Mouse wheel scrolling over Fleet Agents partition adjusts fleet scrolling reliably."""
|
||||||
|
self.tui.focus_pane = "fleet"
|
||||||
|
self.tui.fleet_focus_idx = 0
|
||||||
|
|
||||||
|
# Scroll down in fleet partition (bstate = BUTTON5_PRESSED)
|
||||||
|
bstate_down = getattr(curses, "BUTTON5_PRESSED", 0x200000)
|
||||||
|
self.tui._handle_mouse(mx=10, my=2, bstate=bstate_down)
|
||||||
|
self.assertGreaterEqual(self.tui.fleet_focus_idx, 1)
|
||||||
|
|
||||||
|
# Scroll up in fleet partition (bstate = BUTTON4_PRESSED)
|
||||||
|
bstate_up = getattr(curses, "BUTTON4_PRESSED", 0x10000)
|
||||||
|
self.tui._handle_mouse(mx=10, my=2, bstate=bstate_up)
|
||||||
|
self.assertEqual(self.tui.fleet_focus_idx, 0)
|
||||||
|
|
||||||
|
def test_sidebar_thread_menu_button_click(self):
|
||||||
|
"""Left-clicking the [⚡] button on a sidebar thread row opens chat_context."""
|
||||||
|
self.tui.modal = None
|
||||||
|
# Sidebar width is 26 for w=100.
|
||||||
|
# Button [⚡] is rendered around sidebar_w - 9 (col 17)
|
||||||
|
# Row 11 is thread 1
|
||||||
|
handled = self.tui._handle_mouse(mx=17, my=11, bstate=curses.BUTTON1_CLICKED)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(self.tui.modal, "chat_context")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sgr_escape_sequence_right_click_opens_chat_context(self):
|
||||||
|
"""SGR 1006 escape sequence fallback parses right-click and opens chat_context."""
|
||||||
|
self.tui.modal = None
|
||||||
|
# Simulate ESC [ < 2 ; 11 ; 12 M (Right click at mx=10, my=11 on thread 1)
|
||||||
|
raw_sgr = [ord('['), ord('<'), ord('2'), ord(';'), ord('1'), ord('1'), ord(';'), ord('1'), ord('2'), ord('M')]
|
||||||
|
with patch.object(self.tui, "_read_escape_sequence", return_value=raw_sgr):
|
||||||
|
handled = self.tui._handle_escape_sequence()
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(self.tui.modal, "chat_context")
|
||||||
|
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
|
||||||
|
|
||||||
|
def test_double_click_on_thread_opens_chat_context(self):
|
||||||
|
"""Double-clicking a sidebar thread row opens chat_context."""
|
||||||
|
self.tui.modal = None
|
||||||
|
bstate = getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0x8)
|
||||||
|
handled = self.tui._handle_mouse(mx=10, my=11, bstate=bstate)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(self.tui.modal, "chat_context")
|
||||||
|
|
||||||
|
def test_click_on_already_active_thread_opens_chat_context(self):
|
||||||
|
"""Left-clicking on the currently active thread in sidechats opens its context menu."""
|
||||||
|
self.tui.modal = None
|
||||||
|
self.tui.focus_pane = "sidechats"
|
||||||
|
# Thread 0 (Main Chat, sess-main-1234) is currently active, located at my = 10
|
||||||
|
handled = self.tui._handle_mouse(mx=5, my=10, bstate=curses.BUTTON1_CLICKED)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(self.tui.modal, "chat_context")
|
||||||
|
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-main-1234")
|
||||||
|
|
||||||
|
def test_click_right_side_of_thread_opens_chat_context(self):
|
||||||
|
"""Left-clicking on the right side of a thread row ([⚡] or [sid]) opens chat_context."""
|
||||||
|
self.tui.modal = None
|
||||||
|
sidebar_w = 26
|
||||||
|
# Click at mx = 20 (right side of thread 1 at my = 11)
|
||||||
|
handled = self.tui._handle_mouse(mx=20, my=11, bstate=curses.BUTTON1_CLICKED)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(self.tui.modal, "chat_context")
|
||||||
|
|
||||||
|
def test_space_key_in_normal_mode_opens_context(self):
|
||||||
|
"""Pressing Space in NORMAL mode opens context menu for focused partition."""
|
||||||
|
self.tui.modal = None
|
||||||
|
self.tui.editor_mode = "NORMAL"
|
||||||
|
self.tui.focus_pane = "sidechats"
|
||||||
|
self.tui.thread_sel_idx = 1
|
||||||
|
handled = self.tui._handle_key(ord(' '))
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(self.tui.modal, "chat_context")
|
||||||
|
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
|
||||||
|
|
||||||
|
def test_shift_and_alt_left_click_opens_chat_context(self):
|
||||||
|
"""Shift+Left Click or Alt+Left Click operates as right-click modifier."""
|
||||||
|
self.tui.modal = None
|
||||||
|
shift_mask = getattr(curses, "BUTTON_SHIFT", 0x4000000)
|
||||||
|
handled = self.tui._handle_mouse(mx=10, my=11, bstate=curses.BUTTON1_CLICKED | shift_mask)
|
||||||
|
self.assertTrue(handled)
|
||||||
|
self.assertEqual(self.tui.modal, "chat_context")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
test_focus_highlight.py — Comprehensive tests verifying active portion highlighting in MuseTUI:
|
||||||
|
1. Sidebar focused (focus_pane="sidebar", editor_mode="NORMAL") highlights sidebar header,
|
||||||
|
vertical pane divider, selected thread cursor, and bottom status bar hints.
|
||||||
|
2. Transcript focused (focus_pane="transcript", editor_mode="NORMAL") highlights transcript header,
|
||||||
|
header underline rule, vertical divider, and bottom status bar hints.
|
||||||
|
3. Composer active (editor_mode="INSERT") prominently highlights bottom composer divider
|
||||||
|
and status hints without distracting pane navigation highlights.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path("/home/super/Projects/NetVM")
|
||||||
|
TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("muse_tui", str(TUI_PATH))
|
||||||
|
muse_mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(muse_mod)
|
||||||
|
|
||||||
|
|
||||||
|
class TestActivePortionHighlighting(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.mock_stdscr = MagicMock()
|
||||||
|
self.mock_stdscr.getmaxyx.return_value = (30, 100)
|
||||||
|
self.tui = muse_mod.MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def record_addstr(win, y, x, text, attr=0):
|
||||||
|
self.calls.append((y, x, text, attr))
|
||||||
|
|
||||||
|
self.tui.safe_addstr = record_addstr
|
||||||
|
self.tui.toast_time = 0
|
||||||
|
self.tui.data.nodes = ["muse", "646", "pip"]
|
||||||
|
self.tui.data.active_node = "muse"
|
||||||
|
self.tui.data.active_thread_id = "test_thread"
|
||||||
|
self.tui.data.active_thread_title = "Work Thread"
|
||||||
|
self.tui.data.threads_cache["muse"] = [
|
||||||
|
{"session_id": "test_thread", "title": "Work Thread", "is_main": False},
|
||||||
|
{"session_id": "main_id", "title": "Main Chat", "is_main": True},
|
||||||
|
]
|
||||||
|
self.tui.data.history_cache[("muse", "test_thread")] = [
|
||||||
|
{"role": "user", "text": "Hello agent", "seq": 1, "message_id": "m1"},
|
||||||
|
{"role": "assistant", "text": "Hello user", "seq": 2, "message_id": "m2"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_sidebar_focused_highlight(self):
|
||||||
|
self.tui.focus_pane = "sidebar"
|
||||||
|
self.tui.editor_mode = "NORMAL"
|
||||||
|
|
||||||
|
# 1. Render Muse View
|
||||||
|
self.calls.clear()
|
||||||
|
self.tui._render_muse_view(2, 0, 24, 100)
|
||||||
|
|
||||||
|
rendered_texts = [c[2] for c in self.calls]
|
||||||
|
full_text = " ".join(rendered_texts)
|
||||||
|
|
||||||
|
# Sidebar header has [● FOCUS] badge
|
||||||
|
self.assertTrue(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts),
|
||||||
|
f"Expected [● FOCUS] in sidebar header. Got:\n{full_text}")
|
||||||
|
|
||||||
|
# Vertical divider has heavy vertical char ┃
|
||||||
|
self.assertTrue(any("┃" in t for t in rendered_texts),
|
||||||
|
"Expected vertical divider '┃' when pane is focused")
|
||||||
|
|
||||||
|
# Transcript header does NOT have [● FOCUSED] badge
|
||||||
|
self.assertFalse(any("[● FOCUSED]" in t for t in rendered_texts),
|
||||||
|
"Transcript header should not be [● FOCUSED] when sidebar is active")
|
||||||
|
|
||||||
|
# 2. Render Bottom Bar
|
||||||
|
self.calls.clear()
|
||||||
|
self.tui._render_bottom_bar(27, 0, 3, 100)
|
||||||
|
|
||||||
|
bottom_texts = [c[2] for c in self.calls]
|
||||||
|
# Active pane divider has SIDEBAR highlight
|
||||||
|
self.assertTrue(any("ACTIVE PANE: SIDEBAR" in t for t in bottom_texts),
|
||||||
|
f"Expected ACTIVE PANE: SIDEBAR in bottom divider. Got:\n{bottom_texts}")
|
||||||
|
# Status hints show SIDEBAR FOCUSED
|
||||||
|
self.assertTrue(any("SIDEBAR FOCUSED" in t for t in bottom_texts),
|
||||||
|
f"Expected SIDEBAR FOCUSED in hints. Got:\n{bottom_texts}")
|
||||||
|
|
||||||
|
def test_transcript_focused_highlight(self):
|
||||||
|
self.tui.focus_pane = "transcript"
|
||||||
|
self.tui.editor_mode = "NORMAL"
|
||||||
|
|
||||||
|
# 1. Render Muse View
|
||||||
|
self.calls.clear()
|
||||||
|
self.tui._render_muse_view(2, 0, 24, 100)
|
||||||
|
|
||||||
|
rendered_texts = [c[2] for c in self.calls]
|
||||||
|
|
||||||
|
# Transcript header has [● FOCUSED] badge
|
||||||
|
self.assertTrue(any("[● FOCUSED] Thread:" in t for t in rendered_texts),
|
||||||
|
f"Expected [● FOCUSED] in transcript header. Got:\n{rendered_texts}")
|
||||||
|
|
||||||
|
# Transcript horizontal divider has heavy rule ━
|
||||||
|
self.assertTrue(any("━" in t for t in rendered_texts),
|
||||||
|
"Expected transcript underline divider '━' when transcript is focused")
|
||||||
|
|
||||||
|
# Sidebar header does NOT have [● FOCUS] badge
|
||||||
|
self.assertFalse(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts),
|
||||||
|
"Sidebar should not have [● FOCUS] when transcript is active")
|
||||||
|
|
||||||
|
# 2. Render Bottom Bar
|
||||||
|
self.calls.clear()
|
||||||
|
self.tui._render_bottom_bar(27, 0, 3, 100)
|
||||||
|
|
||||||
|
bottom_texts = [c[2] for c in self.calls]
|
||||||
|
self.assertTrue(any("ACTIVE PANE: TRANSCRIPT" in t for t in bottom_texts),
|
||||||
|
f"Expected ACTIVE PANE: TRANSCRIPT in bottom divider. Got:\n{bottom_texts}")
|
||||||
|
self.assertTrue(any("TRANSCRIPT FOCUSED" in t for t in bottom_texts),
|
||||||
|
f"Expected TRANSCRIPT FOCUSED in hints. Got:\n{bottom_texts}")
|
||||||
|
|
||||||
|
def test_insert_mode_composer_highlight(self):
|
||||||
|
self.tui.editor_mode = "INSERT"
|
||||||
|
|
||||||
|
# 1. Render Bottom Bar
|
||||||
|
self.calls.clear()
|
||||||
|
self.tui._render_bottom_bar(27, 0, 3, 100)
|
||||||
|
|
||||||
|
bottom_texts = [c[2] for c in self.calls]
|
||||||
|
self.assertTrue(any("COMPOSER: INSERT MODE ACTIVE" in t for t in bottom_texts),
|
||||||
|
f"Expected COMPOSER: INSERT MODE ACTIVE in divider. Got:\n{bottom_texts}")
|
||||||
|
self.assertTrue(any("INSERT ACTIVE" in t for t in bottom_texts),
|
||||||
|
f"Expected INSERT ACTIVE in hints. Got:\n{bottom_texts}")
|
||||||
|
|
||||||
|
# 2. Render Muse View - neither pane should show [● FOCUS] / [● FOCUSED]
|
||||||
|
self.calls.clear()
|
||||||
|
self.tui._render_muse_view(2, 0, 24, 100)
|
||||||
|
|
||||||
|
rendered_texts = [c[2] for c in self.calls]
|
||||||
|
self.assertFalse(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts),
|
||||||
|
"Sidebar should not claim active focus in INSERT mode")
|
||||||
|
self.assertFalse(any("[● FOCUSED] Thread:" in t for t in rendered_texts),
|
||||||
|
"Transcript should not claim active focus in INSERT mode")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -11,6 +11,7 @@ test_main_nav.py — Comprehensive unit and integration test suite for:
|
|||||||
import unittest
|
import unittest
|
||||||
import subprocess
|
import subprocess
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -56,8 +57,8 @@ class TestSidechatNavigationLogic(unittest.TestCase):
|
|||||||
import dm
|
import dm
|
||||||
# Well-known mappings
|
# Well-known mappings
|
||||||
self.assertEqual(dm.resolve_sidechat_target("main"), "main")
|
self.assertEqual(dm.resolve_sidechat_target("main"), "main")
|
||||||
self.assertEqual(dm.resolve_sidechat_target("646 tasks"), "1dfb3199-2f99-446c-83e3-848ae2da0a12")
|
hb_uuid = dm.resolve_sidechat_target("heartbeat")
|
||||||
self.assertEqual(dm.resolve_sidechat_target("heartbeat"), "757198c3-c1b2-48b8-ba2b-062c84f71b02")
|
self.assertTrue(bool(re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", hb_uuid)))
|
||||||
|
|
||||||
def test_uuid_regex_detection(self):
|
def test_uuid_regex_detection(self):
|
||||||
import dm, re
|
import dm, re
|
||||||
|
|||||||
Reference in New Issue
Block a user