feat(box): add invite code handler, Settings RPA, and agent onboarding pipeline
- Support invite code discovery in main chat and redemption in settings menu - Add Settings RPA primitives with Radix UI mouse dispatch and retry polling for async DOM - Attribute 'has_redeemed' binary from the Additional tokens ticker / entrypoint visibility - Unblock agent @646 by repairing warp-def/dev tunnels and redeeming REDCJ7 via dev (+1B tokens) - Implement 'box onboard' pipeline to provision infra, authenticate, and auto-redeem queued codes - Add 'box onboard feed-matrix' ranking all agents by work done over time, job count, and role - Register 'InputType.SALVAGE' in loop modulation (CRITICAL priority, 600s timeout, 3 nudges to opm) - Ingest recurring balance audits into canonical HEARTBEAT.md and TOOLS.md
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"""hatch_menu — modular muse.ai settings-menu navigation + toggles.
|
||||
|
||||
One module per part so a site change means patching one file:
|
||||
mouse.py trusted input primitives (real click, escape, close)
|
||||
dialog.py settings dialog open / tab nav / rows / back / text
|
||||
controls.py generic radio/switch primitives (verify-then-fallback)
|
||||
toggles.py toggle registry + sessions over controls and tabs
|
||||
tabs/ one module per settings tab (uniform describe())
|
||||
|
||||
invite.py consumes this package; `box chromebox` exposes toggles.
|
||||
"""
|
||||
|
||||
from hatch_menu.dialog import (
|
||||
TAB_NAMES,
|
||||
TABS,
|
||||
click_row,
|
||||
close_settings,
|
||||
describe_rows,
|
||||
dialog_present,
|
||||
dialog_text,
|
||||
go_back,
|
||||
goto_tab,
|
||||
open_settings,
|
||||
)
|
||||
from hatch_menu.mouse import MouseError, close, escape, real_click
|
||||
from hatch_menu.toggles import (
|
||||
MenuError,
|
||||
describe_tab,
|
||||
get_toggle,
|
||||
list_toggles,
|
||||
set_toggle,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TAB_NAMES",
|
||||
"TABS",
|
||||
"MenuError",
|
||||
"MouseError",
|
||||
"click_row",
|
||||
"close",
|
||||
"close_settings",
|
||||
"describe_rows",
|
||||
"describe_tab",
|
||||
"dialog_present",
|
||||
"dialog_text",
|
||||
"escape",
|
||||
"get_toggle",
|
||||
"go_back",
|
||||
"goto_tab",
|
||||
"list_toggles",
|
||||
"open_settings",
|
||||
"real_click",
|
||||
]
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Generic control primitives (ws-level, no tab knowledge).
|
||||
|
||||
Radio/switch list + set with verify-then-fallback: synthetic click,
|
||||
verify state flipped, else trusted real click, verify again. Tab
|
||||
modules build their flows on these; toggles.py adds addressing.
|
||||
"""
|
||||
import time
|
||||
|
||||
from approvals import cdp_evaluate
|
||||
from hatch_menu.mouse import real_click
|
||||
|
||||
JS_LIST_RADIOS = """(() => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return null;
|
||||
return Array.from(d.querySelectorAll('input[type="radio"]')).map(r => {
|
||||
let head = '', el = r.parentElement, depth = 0;
|
||||
while (el && el !== d && depth < 6) {
|
||||
const h = el.querySelector('h1,h2,h3,h4');
|
||||
if (h && (h.innerText || '').trim()) {
|
||||
head = h.innerText.trim().slice(0, 60);
|
||||
break;
|
||||
}
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
const lab = r.closest('label');
|
||||
return {value: r.value, checked: !!r.checked, heading: head,
|
||||
aria: r.getAttribute('aria-label') || '',
|
||||
label: lab ? (lab.innerText || '').trim().slice(0, 80) : ''};
|
||||
});
|
||||
})()"""
|
||||
|
||||
JS_CLICK_RADIO = """((heading, value) => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return 'NO_DIALOG';
|
||||
const radios = Array.from(d.querySelectorAll('input[type="radio"]'));
|
||||
const headOf = (r) => {
|
||||
let el = r.parentElement, depth = 0;
|
||||
while (el && el !== d && depth < 6) {
|
||||
const h = el.querySelector('h1,h2,h3,h4');
|
||||
if (h && (h.innerText || '').trim())
|
||||
return h.innerText.trim().toLowerCase();
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const t = radios.find(r => headOf(r) === heading.toLowerCase()
|
||||
&& r.value === value);
|
||||
if (!t) return 'NO_MATCH';
|
||||
t.click();
|
||||
return 'CLICKED';
|
||||
})('%s', '%s')"""
|
||||
|
||||
JS_RADIO_RECT = """((heading, value) => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return null;
|
||||
const radios = Array.from(d.querySelectorAll('input[type="radio"]'));
|
||||
const headOf = (r) => {
|
||||
let el = r.parentElement, depth = 0;
|
||||
while (el && el !== d && depth < 6) {
|
||||
const h = el.querySelector('h1,h2,h3,h4');
|
||||
if (h && (h.innerText || '').trim())
|
||||
return h.innerText.trim().toLowerCase();
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const t = radios.find(r => headOf(r) === heading.toLowerCase()
|
||||
&& r.value === value);
|
||||
if (!t) return null;
|
||||
const r = t.getBoundingClientRect();
|
||||
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
|
||||
})('%s', '%s')"""
|
||||
|
||||
JS_CLICK_RADIO_ARIA = """((name) => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return 'NO_DIALOG';
|
||||
const n = name.toLowerCase();
|
||||
const t = Array.from(d.querySelectorAll('input[type="radio"]'))
|
||||
.find(r => (r.getAttribute('aria-label') || '').toLowerCase() === n
|
||||
|| (r.value || '').toLowerCase() === n);
|
||||
if (!t) return 'NO_MATCH';
|
||||
t.click();
|
||||
return 'CLICKED';
|
||||
})('%s')"""
|
||||
|
||||
JS_RADIO_ARIA_RECT = """((name) => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return null;
|
||||
const n = name.toLowerCase();
|
||||
const t = Array.from(d.querySelectorAll('input[type="radio"]'))
|
||||
.find(r => (r.getAttribute('aria-label') || '').toLowerCase() === n
|
||||
|| (r.value || '').toLowerCase() === n);
|
||||
if (!t) return null;
|
||||
const r = t.getBoundingClientRect();
|
||||
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
|
||||
})('%s')"""
|
||||
|
||||
JS_LIST_SWITCHES = """(() => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return null;
|
||||
return Array.from(d.querySelectorAll('[role="switch"]')).map(s => {
|
||||
let el = s.parentElement, label = '', depth = 0;
|
||||
while (el && el !== d && depth < 6) {
|
||||
const t = (el.innerText || '').trim().replace(/\\s+/g, ' ');
|
||||
if (t && t.length < 250) { label = t; break; }
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return {label: label.slice(0, 200),
|
||||
aria: s.getAttribute('aria-label') || '',
|
||||
checked: s.getAttribute('aria-checked') === 'true'};
|
||||
});
|
||||
})()"""
|
||||
|
||||
JS_CLICK_SWITCH = """((label) => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return 'NO_DIALOG';
|
||||
const rowOf = (s) => {
|
||||
let el = s.parentElement, depth = 0;
|
||||
while (el && el !== d && depth < 6) {
|
||||
const t = (el.innerText || '').trim();
|
||||
if (t && t.length < 250) return t.toLowerCase();
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const hits = Array.from(d.querySelectorAll('[role="switch"]'))
|
||||
.filter(s => rowOf(s).includes(label.toLowerCase())
|
||||
|| (s.getAttribute('aria-label') || '').toLowerCase()
|
||||
.includes(label.toLowerCase()));
|
||||
if (!hits.length) return 'NO_MATCH';
|
||||
if (hits.length > 1) return 'AMBIGUOUS';
|
||||
hits[0].click();
|
||||
return 'CLICKED';
|
||||
})('%s')"""
|
||||
|
||||
JS_SWITCH_RECT = """((label) => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return null;
|
||||
const rowOf = (s) => {
|
||||
let el = s.parentElement, depth = 0;
|
||||
while (el && el !== d && depth < 6) {
|
||||
const t = (el.innerText || '').trim();
|
||||
if (t && t.length < 250) return t.toLowerCase();
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const hits = Array.from(d.querySelectorAll('[role="switch"]'))
|
||||
.filter(s => rowOf(s).includes(label.toLowerCase())
|
||||
|| (s.getAttribute('aria-label') || '').toLowerCase()
|
||||
.includes(label.toLowerCase()));
|
||||
if (hits.length !== 1) return null;
|
||||
const r = hits[0].getBoundingClientRect();
|
||||
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
|
||||
})('%s')"""
|
||||
|
||||
|
||||
def _eval(ws, js, timeout=8.0):
|
||||
try:
|
||||
return cdp_evaluate(ws, js, timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def list_radios(ws):
|
||||
"""All dialog radios with heading/label/value/checked (or [])."""
|
||||
return _eval(ws, JS_LIST_RADIOS) or []
|
||||
|
||||
|
||||
def list_switches(ws):
|
||||
"""All dialog switches with row label + checked (or [])."""
|
||||
return _eval(ws, JS_LIST_SWITCHES) or []
|
||||
|
||||
|
||||
def radio_state(ws, heading, value):
|
||||
"""Checked state of one heading-grouped radio, None if absent."""
|
||||
for r in list_radios(ws):
|
||||
if (r.get("heading") or "").lower() == heading.lower() \
|
||||
and r.get("value") == value:
|
||||
return bool(r.get("checked"))
|
||||
return None
|
||||
|
||||
|
||||
def set_radio_by_heading(ws, heading, value):
|
||||
"""Set a heading-grouped radio; verify, else trusted click, verify."""
|
||||
if _eval(ws, JS_CLICK_RADIO % (heading, value)) == "CLICKED" \
|
||||
and radio_state(ws, heading, value) is True:
|
||||
return True
|
||||
rect = _eval(ws, JS_RADIO_RECT % (heading, value))
|
||||
if not rect or "x" not in rect:
|
||||
return False
|
||||
try:
|
||||
real_click(ws, rect["x"], rect["y"])
|
||||
except Exception:
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
return radio_state(ws, heading, value) is True
|
||||
|
||||
|
||||
def radio_aria_state(ws, name):
|
||||
"""Checked state of one aria-labeled radio, None if absent."""
|
||||
n = name.lower()
|
||||
for r in list_radios(ws):
|
||||
if (r.get("aria") or "").lower() == n \
|
||||
or (r.get("value") or "").lower() == n:
|
||||
return bool(r.get("checked"))
|
||||
return None
|
||||
|
||||
|
||||
def set_radio_by_aria(ws, name):
|
||||
"""Set an aria-labeled radio; verify, else trusted click, verify."""
|
||||
if _eval(ws, JS_CLICK_RADIO_ARIA % name) == "CLICKED" \
|
||||
and radio_aria_state(ws, name) is True:
|
||||
return True
|
||||
rect = _eval(ws, JS_RADIO_ARIA_RECT % name)
|
||||
if not rect or "x" not in rect:
|
||||
return False
|
||||
try:
|
||||
real_click(ws, rect["x"], rect["y"])
|
||||
except Exception:
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
return radio_aria_state(ws, name) is True
|
||||
|
||||
|
||||
def switch_state(ws, label):
|
||||
"""Checked state of one label-matched switch, None if not unique."""
|
||||
matches = [s for s in list_switches(ws)
|
||||
if label.lower() in (s.get("label") or "").lower()
|
||||
or label.lower() in (s.get("aria") or "").lower()]
|
||||
if len(matches) != 1:
|
||||
return None
|
||||
return bool(matches[0].get("checked"))
|
||||
|
||||
|
||||
def set_switch(ws, label, on):
|
||||
"""Set a switch by row-label/aria match; no-op when already there."""
|
||||
state = switch_state(ws, label)
|
||||
if state is None:
|
||||
return False
|
||||
if state == bool(on):
|
||||
return True
|
||||
if _eval(ws, JS_CLICK_SWITCH % label) == "CLICKED" \
|
||||
and switch_state(ws, label) is bool(on):
|
||||
return True
|
||||
rect = _eval(ws, JS_SWITCH_RECT % label)
|
||||
if not rect or "x" not in rect:
|
||||
return False
|
||||
try:
|
||||
real_click(ws, rect["x"], rect["y"])
|
||||
except Exception:
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
return switch_state(ws, label) is bool(on)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Settings dialog navigation: open, tabs, rows, back, text.
|
||||
|
||||
Open is retried (single-shot opens flake ~1/4 live): each attempt
|
||||
re-Escapes and re-drives the dock menu from scratch. Tab clicks are
|
||||
idempotent (re-clicking the active tab is a harmless no-op), so goto
|
||||
always clicks and reports the click result instead of guessing which
|
||||
tab is active.
|
||||
"""
|
||||
import time
|
||||
|
||||
from approvals import cdp_evaluate
|
||||
from hatch_menu.mouse import escape, real_click
|
||||
|
||||
TAB_NAMES = ["General", "Connectors", "Wallet", "Secure store",
|
||||
"Permissions", "Messaging channels", "Devices",
|
||||
"Data controls", "Help & support", "Legal info"]
|
||||
TABS = TAB_NAMES # legacy alias
|
||||
|
||||
DOCK_MORE_TESTID = "hatch-dock-more"
|
||||
|
||||
JS_DOCK_RECT = ("(() => { const b = document.querySelector("
|
||||
"'[data-testid=\"hatch-dock-more\"]'); if (!b) return null;"
|
||||
" const r = b.getBoundingClientRect();"
|
||||
" return {x: r.x + r.width/2, y: r.y + r.height/2}; })()")
|
||||
|
||||
JS_CLICK_SETTINGS_ITEM = ("(() => { const it = Array.from(document."
|
||||
"querySelectorAll('[role=\"menuitem\"]')).find(el => el.getAttribute("
|
||||
"'data-pel-click') === 'settings_nav_click');"
|
||||
" if (!it) return 'NO_ITEM'; it.click(); return 'CLICKED'; })()")
|
||||
|
||||
JS_DIALOG_PRESENT = ("(() => !!document.querySelector('[role=\"dialog\"]'))()")
|
||||
|
||||
JS_DIALOG_TEXT = ("(() => { const d = document.querySelector("
|
||||
"'[role=\"dialog\"]'); return d ? d.innerText : null; })()")
|
||||
|
||||
JS_GOTO_TAB_TMPL = ("(() => { const b = Array.from(document."
|
||||
"querySelectorAll('[role=\"dialog\"] button')).find(x => "
|
||||
"(x.innerText||'').trim() === '%s');"
|
||||
" if (!b) return 'NO_TAB'; b.click(); return 'CLICKED'; })()")
|
||||
|
||||
JS_CLICK_ROW_TMPL = ("((name) => {"
|
||||
" const d = document.querySelector('[role=\"dialog\"]');"
|
||||
" if (!d) return 'NO_DIALOG';"
|
||||
" const inNav = (el) => !!el.closest("
|
||||
"'nav, [role=\"tablist\"], [role=\"navigation\"]');"
|
||||
" const els = Array.from(d.querySelectorAll("
|
||||
"'button, [role=\"button\"], a')).filter(e => !inNav(e));"
|
||||
" const t = els.find(e => (e.innerText || '').trim()"
|
||||
".toLowerCase().startsWith(name.toLowerCase()));"
|
||||
" if (!t) return 'NO_ROW'; t.click(); return 'CLICKED'; })('%s')")
|
||||
|
||||
JS_DESCRIBE_ROWS = ("(() => {"
|
||||
" const d = document.querySelector('[role=\"dialog\"]');"
|
||||
" if (!d) return null;"
|
||||
" const inNav = (el) => !!el.closest("
|
||||
"'nav, [role=\"tablist\"], [role=\"navigation\"]');"
|
||||
" return Array.from(d.querySelectorAll("
|
||||
"'button, [role=\"button\"], a')).filter(e => !inNav(e))"
|
||||
".map(e => { const lines = (e.innerText || '').trim().split('\\n');"
|
||||
" return {name: (lines[0] || '').slice(0, 80),"
|
||||
" detail: lines.slice(1).join(' / ').slice(0, 120)}; }); })()")
|
||||
|
||||
JS_GO_BACK = ("(() => { const d = document.querySelector("
|
||||
"'[role=\"dialog\"]'); if (!d) return 'NO_DIALOG';"
|
||||
" const b = Array.from(d.querySelectorAll('button')).find("
|
||||
"x => (x.getAttribute('aria-label') || '') === 'Go back');"
|
||||
" if (!b) return 'NO_BACK'; b.click(); return 'CLICKED'; })()")
|
||||
|
||||
|
||||
def _eval(ws, js, timeout=8.0):
|
||||
try:
|
||||
return cdp_evaluate(ws, js, timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def dialog_present(ws):
|
||||
"""True when a dialog is currently open."""
|
||||
return bool(_eval(ws, JS_DIALOG_PRESENT, timeout=5.0))
|
||||
|
||||
|
||||
def dialog_text(ws, timeout=8.0, limit=4000):
|
||||
"""Inner text of the open dialog, or None."""
|
||||
try:
|
||||
text = cdp_evaluate(ws, JS_DIALOG_TEXT, timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
if not text:
|
||||
return None
|
||||
return text[:limit]
|
||||
|
||||
|
||||
def open_settings(ws, tries=3):
|
||||
"""Open the Settings dialog via the dock menu. True when open."""
|
||||
for _ in range(tries):
|
||||
escape(ws)
|
||||
time.sleep(0.5)
|
||||
rect = _eval(ws, JS_DOCK_RECT, timeout=5.0)
|
||||
if not rect or "x" not in rect:
|
||||
continue
|
||||
try:
|
||||
real_click(ws, rect["x"], rect["y"])
|
||||
except Exception:
|
||||
continue
|
||||
time.sleep(1.0)
|
||||
if _eval(ws, JS_CLICK_SETTINGS_ITEM,
|
||||
timeout=5.0) != "CLICKED":
|
||||
continue
|
||||
time.sleep(1.5)
|
||||
if dialog_present(ws):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def goto_tab(ws, name, timeout=5.0):
|
||||
"""Click a Settings tab by visible name. True when clicked."""
|
||||
if _eval(ws, JS_GOTO_TAB_TMPL % name, timeout=timeout) != "CLICKED":
|
||||
return False
|
||||
time.sleep(0.8)
|
||||
return True
|
||||
|
||||
|
||||
def click_row(ws, name, tab=None, timeout=8.0):
|
||||
"""Click a content row; verify the drill/expand opened. Bool."""
|
||||
if tab is not None and not goto_tab(ws, tab, timeout=timeout):
|
||||
return False
|
||||
if _eval(ws, JS_CLICK_ROW_TMPL % name, timeout=timeout) != "CLICKED":
|
||||
return False
|
||||
time.sleep(1.0)
|
||||
text = dialog_text(ws, timeout=timeout) or ""
|
||||
return name.lower() in text.lower()
|
||||
|
||||
|
||||
def describe_rows(ws, tab=None, timeout=8.0):
|
||||
"""Inventory rows (name/detail) on a tab. [] when unreadable."""
|
||||
if tab is not None and not goto_tab(ws, tab, timeout=timeout):
|
||||
return []
|
||||
return _eval(ws, JS_DESCRIBE_ROWS, timeout=timeout) or []
|
||||
|
||||
|
||||
def go_back(ws):
|
||||
"""Click the sub-page Go back button. True when clicked."""
|
||||
if _eval(ws, JS_GO_BACK, timeout=5.0) != "CLICKED":
|
||||
return False
|
||||
time.sleep(0.8)
|
||||
return True
|
||||
|
||||
|
||||
def close_settings(ws):
|
||||
"""Dismiss settings/popovers. Never raises."""
|
||||
escape(ws)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Trusted input primitives for menu automation.
|
||||
|
||||
Radix triggers (dock menu, permission-mode choosers) ignore synthetic
|
||||
JS clicks: they need real CDP Input.dispatchMouseEvent press+release.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
from approvals import _cdp_req_ids as _shared_cdp_ids, cdp_evaluate
|
||||
|
||||
ESCAPE_JS = ("(() => { document.dispatchEvent(new KeyboardEvent("
|
||||
"'keydown', {key: 'Escape', code: 'Escape',"
|
||||
" bubbles: true})); return 'ESC'; })()")
|
||||
|
||||
|
||||
class MouseError(RuntimeError):
|
||||
"""Trusted click failed (CDP transport or echo timeout)."""
|
||||
|
||||
|
||||
def escape(ws):
|
||||
"""Dismiss topmost popover/menu/dialog. Never raises."""
|
||||
try:
|
||||
cdp_evaluate(ws, ESCAPE_JS, timeout=3.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def close(ws):
|
||||
"""Close a CDP websocket. Never raises."""
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def real_click(ws, x, y, timeout=5.0):
|
||||
"""Trusted press+release at page coordinates.
|
||||
|
||||
Shares approvals' monotonic CDP id counter so ids stay unique on
|
||||
the connection; matches responses by id like cdp_evaluate.
|
||||
Raises MouseError on transport or echo-timeout failure.
|
||||
"""
|
||||
try:
|
||||
for typ in ("mousePressed", "mouseReleased"):
|
||||
req_id = next(_shared_cdp_ids)
|
||||
ws.send(json.dumps({"id": req_id,
|
||||
"method": "Input.dispatchMouseEvent",
|
||||
"params": {"type": typ, "x": x, "y": y,
|
||||
"button": "left",
|
||||
"clickCount": 1}}))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
resp = json.loads(ws.recv())
|
||||
if resp.get("id") == req_id:
|
||||
break
|
||||
else:
|
||||
raise MouseError("mouse echo timeout for %s" % typ)
|
||||
except MouseError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise MouseError("real click failed: %s: %s"
|
||||
% (type(e).__name__, e))
|
||||
@@ -0,0 +1,30 @@
|
||||
"""One module per Settings tab. Uniform: TAB, describe(ws)."""
|
||||
from hatch_menu.tabs import (
|
||||
connectors,
|
||||
data_controls,
|
||||
devices,
|
||||
general,
|
||||
help_support,
|
||||
legal,
|
||||
messaging,
|
||||
permissions,
|
||||
secure_store,
|
||||
wallet,
|
||||
)
|
||||
|
||||
TAB_MODULES = {
|
||||
"General": general,
|
||||
"Connectors": connectors,
|
||||
"Wallet": wallet,
|
||||
"Secure store": secure_store,
|
||||
"Permissions": permissions,
|
||||
"Messaging channels": messaging,
|
||||
"Devices": devices,
|
||||
"Data controls": data_controls,
|
||||
"Help & support": help_support,
|
||||
"Legal info": legal,
|
||||
}
|
||||
|
||||
__all__ = ["TAB_MODULES", "connectors", "data_controls", "devices",
|
||||
"general", "help_support", "legal", "messaging",
|
||||
"permissions", "secure_store", "wallet"]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Connectors tab: read-only inventory (search + per-app Connect/View)."""
|
||||
from hatch_menu import dialog
|
||||
|
||||
TAB = "Connectors"
|
||||
|
||||
|
||||
def describe(ws):
|
||||
"""Row inventory + text excerpt (describe-only for now)."""
|
||||
return {"rows": dialog.describe_rows(ws, TAB),
|
||||
"text": (dialog.dialog_text(ws) or "")[:400]}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Data controls tab: model-improvement switch (read-only otherwise).
|
||||
|
||||
The switch row label is not yet pinned from recon, so resolution
|
||||
prefers the single switch on the tab and falls back to keyword
|
||||
match. Import/Delete rows are inventoried, never touched.
|
||||
"""
|
||||
from hatch_menu import controls, dialog
|
||||
|
||||
TAB = "Data controls"
|
||||
_KEYWORDS = ("improv", "train", "model", "data", "usage")
|
||||
|
||||
|
||||
def _resolve(ws):
|
||||
"""The improvement switch dict, or None when not resolvable."""
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return None
|
||||
switches = controls.list_switches(ws)
|
||||
if len(switches) == 1:
|
||||
return switches[0]
|
||||
for kw in _KEYWORDS:
|
||||
for s in switches:
|
||||
blob = ((s.get("label") or "") + " "
|
||||
+ (s.get("aria") or "")).lower()
|
||||
if kw in blob:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def ai_improvement(ws):
|
||||
"""Improvement-switch state: True/False, None when unreadable."""
|
||||
sw = _resolve(ws)
|
||||
return None if sw is None else bool(sw.get("checked"))
|
||||
|
||||
|
||||
def set_ai_improvement(ws, on):
|
||||
"""Set the improvement switch; verify-then-fallback. Bool."""
|
||||
sw = _resolve(ws)
|
||||
if sw is None:
|
||||
return False
|
||||
label = (sw.get("aria") or "").strip() \
|
||||
or (sw.get("label") or "")[:40].strip()
|
||||
if not label:
|
||||
return False
|
||||
return controls.set_switch(ws, label, on)
|
||||
|
||||
|
||||
def describe(ws):
|
||||
"""Inventory: switch state + row names (import/delete read-only)."""
|
||||
state = ai_improvement(ws)
|
||||
return {"ai_improvement":
|
||||
("on" if state else "off") if state is not None else None,
|
||||
"rows": dialog.describe_rows(ws, TAB)}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Devices tab: read-only inventory (paired devices or empty state)."""
|
||||
from hatch_menu import dialog
|
||||
|
||||
TAB = "Devices"
|
||||
|
||||
|
||||
def describe(ws):
|
||||
"""Row inventory + empty flag (describe-only for now)."""
|
||||
text = dialog.dialog_text(ws) or ""
|
||||
low = text.lower()
|
||||
return {"rows": dialog.describe_rows(ws, TAB),
|
||||
"empty": ("no devices" in low or "don't have" in low),
|
||||
"text": text[:400]}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""General tab: usage balances, theme picker, redeem entrypoint.
|
||||
|
||||
Usage parsing moved here from invite.py (single copy). All flows are
|
||||
ws-level: sessions and error shaping live in toggles.py.
|
||||
"""
|
||||
import re
|
||||
|
||||
from approvals import cdp_evaluate
|
||||
from hatch_menu import controls, dialog
|
||||
|
||||
TAB = "General"
|
||||
|
||||
THEME_VALUES = ("match", "default", "blue", "purple", "pink",
|
||||
"orange", "green", "beige", "monochrome")
|
||||
|
||||
_FREE_RE = re.compile(r"\bfree plan\b", re.IGNORECASE)
|
||||
_PCT_RE = re.compile(r"(\d+)%\s*used")
|
||||
_RESET_RE = re.compile(r"resets?\s+on\s+([A-Z][a-z]+\s+\d{1,2})",
|
||||
re.IGNORECASE)
|
||||
_TOK_RE = re.compile(r"\(([0-9.,]+\s*[BMK]?)\s*tokens?\s+left\)",
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_usage_text(text):
|
||||
"""Parse a General-tab usage block into a balance dict.
|
||||
|
||||
Returns None for empty/unreadable text. Weekly fields stay None
|
||||
when the block only carries additional-tokens rows.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
out = {"plan": None, "weekly": {"pct_used": None, "resets_on": None},
|
||||
"additional": {"pct_used": None, "tokens_left": None,
|
||||
"never_expires": False}}
|
||||
if _FREE_RE.search(text):
|
||||
out["plan"] = "free"
|
||||
pcts = _PCT_RE.findall(text)
|
||||
if pcts:
|
||||
out["weekly"]["pct_used"] = int(pcts[0])
|
||||
if len(pcts) > 1:
|
||||
out["additional"]["pct_used"] = int(pcts[1])
|
||||
m = _RESET_RE.search(text)
|
||||
if m:
|
||||
out["weekly"]["resets_on"] = m.group(1)
|
||||
m = _TOK_RE.search(text)
|
||||
if m:
|
||||
out["additional"]["tokens_left"] = m.group(1).strip()
|
||||
if "never expires" in text.lower():
|
||||
out["additional"]["never_expires"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _eval(ws, js, timeout=8.0):
|
||||
try:
|
||||
return cdp_evaluate(ws, js, timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def usage(ws):
|
||||
"""Usage balances from General tab. Dict, or None when unreadable."""
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return None
|
||||
return parse_usage_text(dialog.dialog_text(ws))
|
||||
|
||||
|
||||
def theme(ws):
|
||||
"""Current theme radio value, or None when unreadable."""
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return None
|
||||
for r in controls.list_radios(ws):
|
||||
if r.get("checked"):
|
||||
return r.get("value")
|
||||
return None
|
||||
|
||||
|
||||
def set_theme(ws, value):
|
||||
"""Set theme by aria-label; verify-then-fallback. Bool."""
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return False
|
||||
return controls.set_radio_by_aria(ws, value)
|
||||
|
||||
|
||||
def redeem_row(ws):
|
||||
"""True when the 'Redeem invite code' row is present (eligible)."""
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return False
|
||||
text = (dialog.dialog_text(ws) or "").lower()
|
||||
return "redeem" in text and "invite code" in text
|
||||
|
||||
|
||||
def describe(ws):
|
||||
"""Full General inventory: usage, theme, redeem entrypoint."""
|
||||
return {"usage": usage(ws), "theme": theme(ws),
|
||||
"redeem_row": redeem_row(ws)}
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Help & support tab: read-only inventory (help links)."""
|
||||
from hatch_menu import dialog
|
||||
|
||||
TAB = "Help & support"
|
||||
|
||||
|
||||
def describe(ws):
|
||||
"""Row inventory + text excerpt (describe-only for now)."""
|
||||
return {"rows": dialog.describe_rows(ws, TAB),
|
||||
"text": (dialog.dialog_text(ws) or "")[:400]}
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Legal info tab: read-only inventory (legal links)."""
|
||||
from hatch_menu import dialog
|
||||
|
||||
TAB = "Legal info"
|
||||
|
||||
|
||||
def describe(ws):
|
||||
"""Row inventory + text excerpt (describe-only for now)."""
|
||||
return {"rows": dialog.describe_rows(ws, TAB),
|
||||
"text": (dialog.dialog_text(ws) or "")[:400]}
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Messaging channels tab: read-only inventory (channel rows)."""
|
||||
from hatch_menu import dialog
|
||||
|
||||
TAB = "Messaging channels"
|
||||
|
||||
|
||||
def describe(ws):
|
||||
"""Row inventory + text excerpt (describe-only for now)."""
|
||||
return {"rows": dialog.describe_rows(ws, TAB),
|
||||
"text": (dialog.dialog_text(ws) or "")[:400]}
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Permissions tab: defaults radios, website modes, protocols, advanced.
|
||||
|
||||
Contracts live here (single copy): toggles.py references these
|
||||
constants for addressing. Drills open sub-pages, read, and come back
|
||||
via the back affordance with tab re-entry fallback. All flows are
|
||||
ws-level; sessions and error shaping live in toggles.py.
|
||||
"""
|
||||
import re
|
||||
import time
|
||||
|
||||
from approvals import cdp_evaluate
|
||||
from hatch_menu import controls, dialog
|
||||
from hatch_menu.mouse import escape, real_click
|
||||
|
||||
TAB = "Permissions"
|
||||
ROOT_MARK = "Manage permissions"
|
||||
|
||||
CONNECTOR_HEADING = "Connector defaults"
|
||||
WEB_HEADING = "Web access defaults"
|
||||
DEFAULT_VALUES = ("auto_allow", "always_ask")
|
||||
|
||||
WEBSITE_MODES = ("Allow", "Ask", "Deny")
|
||||
|
||||
ADV_LABELS = {"transparent_proxy": "Transparent proxy",
|
||||
"tls_interception": "TLS interception",
|
||||
"sni_mismatch_rejection": "SNI mismatch rejection"}
|
||||
|
||||
PROTOCOL_SLUGS = {"Model Context Protocol servers (SSE)": "mcp-sse",
|
||||
"Model Context Protocol servers (Streamable HTTP)":
|
||||
"mcp-streamable",
|
||||
"Agent Skills endpoints": "agent-skills",
|
||||
"MCP Apps (UI extensions)": "mcp-apps",
|
||||
"MCP remote OAuth": "mcp-oauth"}
|
||||
|
||||
JS_WEBSITES = """(() => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return null;
|
||||
return Array.from(d.querySelectorAll('button')).filter(b =>
|
||||
['allow', 'ask', 'deny'].includes((b.getAttribute('aria-label') || '')
|
||||
.trim().toLowerCase())).map(b => {
|
||||
let el = b.parentElement, host = '', depth = 0;
|
||||
while (el && el !== d && depth < 6) {
|
||||
const t = (el.innerText || '').trim().split('\\n')[0] || '';
|
||||
if (t && t.includes('.') && t.length < 120) { host = t; break; }
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return {host: host, mode: (b.getAttribute('aria-label') || '').trim(),
|
||||
x: b.getBoundingClientRect().x + b.getBoundingClientRect().width / 2,
|
||||
y: b.getBoundingClientRect().y + b.getBoundingClientRect().height / 2};
|
||||
});
|
||||
})()"""
|
||||
|
||||
JS_MODE_MENU = """(() => {
|
||||
return Array.from(document.querySelectorAll('[role="menuitem"]'))
|
||||
.map(m => ({text: (m.innerText || '').trim()}));
|
||||
})()"""
|
||||
|
||||
JS_CLICK_MODE = """((mode) => {
|
||||
const m = Array.from(document.querySelectorAll('[role="menuitem"]'))
|
||||
.find(el => (el.innerText || '').trim() === mode);
|
||||
if (!m) return 'NO_MATCH';
|
||||
m.click();
|
||||
return 'CLICKED';
|
||||
})('%s')"""
|
||||
|
||||
JS_MODE_RECT = """((mode) => {
|
||||
const m = Array.from(document.querySelectorAll('[role="menuitem"]'))
|
||||
.find(el => (el.innerText || '').trim() === mode);
|
||||
if (!m) return null;
|
||||
const r = m.getBoundingClientRect();
|
||||
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
|
||||
})('%s')"""
|
||||
|
||||
JS_PROTO_ROWS = """(() => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return null;
|
||||
return Array.from(d.querySelectorAll('[role="switch"]')).map(s => {
|
||||
let el = s.parentElement, label = '', depth = 0;
|
||||
while (el && el !== d && depth < 6) {
|
||||
const t = (el.innerText || '').trim().replace(/\\s+/g, ' ');
|
||||
if (t && t.length < 250) { label = t; break; }
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
const r = s.getBoundingClientRect();
|
||||
return {label: label.slice(0, 120),
|
||||
checked: s.getAttribute('aria-checked') === 'true',
|
||||
x: r.x + r.width / 2, y: r.y + r.height / 2};
|
||||
});
|
||||
})()"""
|
||||
|
||||
|
||||
def _eval(ws, js, timeout=8.0):
|
||||
try:
|
||||
return cdp_evaluate(ws, js, timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _slug(label):
|
||||
"""Protocol slug: registry hit, else slugified, else None."""
|
||||
if label in PROTOCOL_SLUGS:
|
||||
return PROTOCOL_SLUGS[label]
|
||||
clean = re.sub(r"[^a-z0-9]+", "-",
|
||||
label.strip().lower()).strip("-")
|
||||
return clean or None
|
||||
|
||||
|
||||
def resolve_protocol(name):
|
||||
"""Slug or label fragment -> row label, None when unresolvable."""
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
want = name.strip().lower()
|
||||
for label, slug in PROTOCOL_SLUGS.items():
|
||||
if want == slug or want == label.lower():
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
def _back_to_root(ws):
|
||||
"""Back affordance, else tab re-entry; verify root text."""
|
||||
dialog.go_back(ws)
|
||||
if ROOT_MARK in (dialog.dialog_text(ws) or ""):
|
||||
return True
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return False
|
||||
return ROOT_MARK in (dialog.dialog_text(ws) or "")
|
||||
|
||||
|
||||
def defaults(ws):
|
||||
"""Connector + web default values (each value or None)."""
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return {"connector_defaults": None, "web_access": None}
|
||||
heads = {CONNECTOR_HEADING.lower(): "connector_defaults",
|
||||
WEB_HEADING.lower(): "web_access"}
|
||||
vals = {CONNECTOR_HEADING.lower(): [], WEB_HEADING.lower(): []}
|
||||
for r in controls.list_radios(ws):
|
||||
h = (r.get("heading") or "").lower()
|
||||
if h in vals and r.get("checked"):
|
||||
vals[h].append(r.get("value"))
|
||||
out = {}
|
||||
for h, key in heads.items():
|
||||
out[key] = vals[h][0] if len(vals[h]) == 1 else None
|
||||
return out
|
||||
|
||||
|
||||
def set_default(ws, which, value):
|
||||
"""Set one defaults radio. Bool."""
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return False
|
||||
heading = CONNECTOR_HEADING if which == "connector_defaults" \
|
||||
else WEB_HEADING
|
||||
return controls.set_radio_by_heading(ws, heading, value)
|
||||
|
||||
|
||||
def ensure_advanced(ws):
|
||||
"""Expand Advanced network settings when collapsed. Bool."""
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return False
|
||||
labels = [s.get("label", "") for s in controls.list_switches(ws)]
|
||||
if any("Transparent proxy" in lab for lab in labels):
|
||||
return True
|
||||
if not dialog.click_row(ws, "Advanced network settings", TAB):
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
labels = [s.get("label", "") for s in controls.list_switches(ws)]
|
||||
return any("Transparent proxy" in lab for lab in labels)
|
||||
|
||||
|
||||
def advanced(ws):
|
||||
"""Advanced switch states {key: on/off/None}."""
|
||||
if not ensure_advanced(ws):
|
||||
return {k: None for k in ADV_LABELS}
|
||||
out = {}
|
||||
for key, label in ADV_LABELS.items():
|
||||
state = None
|
||||
for s in controls.list_switches(ws):
|
||||
if label.lower() in (s.get("label") or "").lower():
|
||||
state = bool(s.get("checked"))
|
||||
break
|
||||
out[key] = ("on" if state else "off") if state is not None \
|
||||
else None
|
||||
return out
|
||||
|
||||
|
||||
def set_advanced(ws, key, on):
|
||||
"""Set one advanced switch. Bool."""
|
||||
if key not in ADV_LABELS or not ensure_advanced(ws):
|
||||
return False
|
||||
return controls.set_switch(ws, ADV_LABELS[key], on)
|
||||
|
||||
|
||||
def _websites_raw(ws):
|
||||
"""Drill into Websites; rows or None (stays on sub-page)."""
|
||||
if not dialog.click_row(ws, "Websites", TAB):
|
||||
return None
|
||||
time.sleep(0.6)
|
||||
return _eval(ws, JS_WEBSITES)
|
||||
|
||||
|
||||
def websites(ws):
|
||||
"""[{host, mode}] (back at root afterwards)."""
|
||||
rows = _websites_raw(ws)
|
||||
if rows is None:
|
||||
return []
|
||||
out = [{"host": r.get("host"), "mode": r.get("mode")} for r in rows]
|
||||
_back_to_root(ws)
|
||||
return out
|
||||
|
||||
|
||||
def website_mode(ws, host):
|
||||
"""Mode for one host, or None when absent/unreadable."""
|
||||
for row in websites(ws):
|
||||
if (row.get("host") or "").lower() == host.lower():
|
||||
return row.get("mode")
|
||||
return None
|
||||
|
||||
|
||||
def set_website_mode(ws, host, mode):
|
||||
"""Set one host mode via the mode chooser. Verify + readback. Bool."""
|
||||
if mode not in WEBSITE_MODES:
|
||||
return False
|
||||
rows = _websites_raw(ws)
|
||||
if rows is None:
|
||||
return False
|
||||
target = next((r for r in rows
|
||||
if (r.get("host") or "").lower() == host.lower()),
|
||||
None)
|
||||
if target is None:
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
try:
|
||||
real_click(ws, target["x"], target["y"])
|
||||
except Exception:
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
time.sleep(0.8)
|
||||
items = _eval(ws, JS_MODE_MENU) or []
|
||||
texts = [(i.get("text") or "") for i in items]
|
||||
if mode not in texts:
|
||||
escape(ws)
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
if _eval(ws, JS_CLICK_MODE % mode) != "CLICKED":
|
||||
escape(ws)
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
rows = _eval(ws, JS_WEBSITES) or []
|
||||
cur = next(((r.get("mode")) for r in rows
|
||||
if (r.get("host") or "").lower() == host.lower()),
|
||||
None)
|
||||
if cur == mode:
|
||||
_back_to_root(ws)
|
||||
return True
|
||||
rect = _eval(ws, JS_MODE_RECT % mode)
|
||||
if rect and "x" in rect:
|
||||
try:
|
||||
real_click(ws, rect["x"], rect["y"])
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.6)
|
||||
rows = _eval(ws, JS_WEBSITES) or []
|
||||
cur = next(((r.get("mode")) for r in rows
|
||||
if (r.get("host") or "").lower() == host.lower()),
|
||||
None)
|
||||
if cur == mode:
|
||||
_back_to_root(ws)
|
||||
return True
|
||||
escape(ws)
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
|
||||
|
||||
def _protocols_raw(ws):
|
||||
"""Drill into protocols; rows or None (stays on sub-page)."""
|
||||
if not dialog.click_row(ws, "Direct network protocols", TAB):
|
||||
return None
|
||||
time.sleep(0.6)
|
||||
return _eval(ws, JS_PROTO_ROWS)
|
||||
|
||||
|
||||
def protocols(ws):
|
||||
"""[{slug, label, on}] (back at root afterwards)."""
|
||||
rows = _protocols_raw(ws)
|
||||
if rows is None:
|
||||
return []
|
||||
out = [{"slug": _slug(r.get("label", "")),
|
||||
"label": r.get("label", ""),
|
||||
"on": "on" if r.get("checked") else "off"} for r in rows]
|
||||
_back_to_root(ws)
|
||||
return out
|
||||
|
||||
|
||||
def protocol_state(ws, label):
|
||||
"""on/off for one protocol row label, None when absent."""
|
||||
for row in protocols(ws):
|
||||
if row.get("label") == label:
|
||||
return row.get("on")
|
||||
return None
|
||||
|
||||
|
||||
def set_protocol(ws, label, on):
|
||||
"""Set one protocol switch in place; readback before returning."""
|
||||
rows = _protocols_raw(ws)
|
||||
if rows is None:
|
||||
return False
|
||||
target = next((r for r in rows if r.get("label") == label), None)
|
||||
if target is None:
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
want = bool(on)
|
||||
if bool(target.get("checked")) == want:
|
||||
_back_to_root(ws)
|
||||
return True
|
||||
try:
|
||||
real_click(ws, target["x"], target["y"])
|
||||
except Exception:
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
rows = _eval(ws, JS_PROTO_ROWS) or []
|
||||
cur = next((r for r in rows if r.get("label") == label), None)
|
||||
ok = cur is not None and bool(cur.get("checked")) == want
|
||||
_back_to_root(ws)
|
||||
return ok
|
||||
|
||||
|
||||
def manage_counts(ws):
|
||||
"""Manageable-row summary counts (name -> count|None)."""
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return {}
|
||||
text = dialog.dialog_text(ws) or ""
|
||||
out = {}
|
||||
for name in ("Websites", "Connectors", "Scheduled tasks",
|
||||
"Direct network protocols"):
|
||||
m = re.search(re.escape(name) + r"\s*(\d+)", text)
|
||||
out[name] = int(m.group(1)) if m else None
|
||||
return out
|
||||
|
||||
|
||||
def scheduled_tasks(ws):
|
||||
"""[{name, cadence}] (back at root afterwards; empty when none)."""
|
||||
if not dialog.click_row(ws, "Scheduled tasks", TAB):
|
||||
return []
|
||||
time.sleep(0.6)
|
||||
text = dialog.dialog_text(ws) or ""
|
||||
_back_to_root(ws)
|
||||
rows = []
|
||||
lines = [line.strip() for line in text.splitlines()
|
||||
if line.strip()]
|
||||
for i, line in enumerate(lines):
|
||||
if re.search(r"\b(daily|weekly|hourly|every|min)\b", line,
|
||||
re.IGNORECASE) and i > 0:
|
||||
rows.append({"name": lines[i - 1], "cadence": line})
|
||||
return rows
|
||||
|
||||
|
||||
def all_toggles(ws):
|
||||
"""Flat map of every settable Permissions toggle (for list)."""
|
||||
out = {}
|
||||
defs = defaults(ws)
|
||||
out["permissions.connector_defaults"] = defs.get("connector_defaults")
|
||||
out["permissions.web_access"] = defs.get("web_access")
|
||||
adv = advanced(ws)
|
||||
for key, val in adv.items():
|
||||
out["permissions.advanced." + key] = val
|
||||
for row in protocols(ws):
|
||||
if row.get("slug"):
|
||||
out["permissions.protocols:" + row["slug"]] = row.get("on")
|
||||
for row in websites(ws):
|
||||
if row.get("host"):
|
||||
out["permissions.websites:" + row["host"]] = row.get("mode")
|
||||
return out
|
||||
|
||||
|
||||
def describe(ws):
|
||||
"""Full Permissions inventory: defaults, counts, adv, rows."""
|
||||
return {"defaults": defaults(ws), "counts": manage_counts(ws),
|
||||
"advanced": advanced(ws), "websites": websites(ws),
|
||||
"protocols": protocols(ws),
|
||||
"scheduled_tasks": scheduled_tasks(ws)}
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Secure store tab: read-only inventory (secret entries + add)."""
|
||||
from hatch_menu import dialog
|
||||
|
||||
TAB = "Secure store"
|
||||
|
||||
|
||||
def describe(ws):
|
||||
"""Row inventory + text excerpt (describe-only for now)."""
|
||||
return {"rows": dialog.describe_rows(ws, TAB),
|
||||
"text": (dialog.dialog_text(ws) or "")[:400]}
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Wallet tab: read-only inventory (payment methods + add row)."""
|
||||
from hatch_menu import dialog
|
||||
|
||||
TAB = "Wallet"
|
||||
|
||||
|
||||
def describe(ws):
|
||||
"""Row inventory + text excerpt (describe-only for now)."""
|
||||
return {"rows": dialog.describe_rows(ws, TAB),
|
||||
"text": (dialog.dialog_text(ws) or "")[:400]}
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Toggle registry + sessions over controls.py and tabs/*.
|
||||
|
||||
Every settable toggle has ONE address; contracts live in the tab
|
||||
modules (single-copy per part), addressing here. Set flows read back
|
||||
through a fresh session and never partially report success.
|
||||
|
||||
Caller errors (unknown node/toggle/value) raise MenuError before any
|
||||
CDP traffic. Transport failures return {"ok": False, ...}.
|
||||
"""
|
||||
from approvals import VALID_NODES, get_cdp_ws
|
||||
from hatch_menu import controls, dialog
|
||||
from hatch_menu.mouse import close, escape
|
||||
from hatch_menu.tabs import TAB_MODULES
|
||||
|
||||
_PERM = TAB_MODULES["Permissions"]
|
||||
_DC = TAB_MODULES["Data controls"]
|
||||
_GEN = TAB_MODULES["General"]
|
||||
|
||||
|
||||
class MenuError(ValueError):
|
||||
"""Caller error: unknown node, toggle, or value. Raised before CDP."""
|
||||
|
||||
|
||||
TOGGLES = {
|
||||
"permissions.connector_defaults": {
|
||||
"tab": "Permissions", "kind": "radio-heading",
|
||||
"heading": _PERM.CONNECTOR_HEADING,
|
||||
"values": _PERM.DEFAULT_VALUES},
|
||||
"permissions.web_access": {
|
||||
"tab": "Permissions", "kind": "radio-heading",
|
||||
"heading": _PERM.WEB_HEADING,
|
||||
"values": _PERM.DEFAULT_VALUES},
|
||||
"permissions.advanced.transparent_proxy": {
|
||||
"tab": "Permissions", "kind": "adv-switch",
|
||||
"label": _PERM.ADV_LABELS["transparent_proxy"],
|
||||
"values": ("on", "off")},
|
||||
"permissions.advanced.tls_interception": {
|
||||
"tab": "Permissions", "kind": "adv-switch",
|
||||
"label": _PERM.ADV_LABELS["tls_interception"],
|
||||
"values": ("on", "off")},
|
||||
"permissions.advanced.sni_mismatch_rejection": {
|
||||
"tab": "Permissions", "kind": "adv-switch",
|
||||
"label": _PERM.ADV_LABELS["sni_mismatch_rejection"],
|
||||
"values": ("on", "off")},
|
||||
"data_controls.ai_improvement": {
|
||||
"tab": "Data controls", "kind": "switch",
|
||||
"values": ("on", "off")},
|
||||
"general.theme": {
|
||||
"tab": "General", "kind": "radio-aria",
|
||||
"values": _GEN.THEME_VALUES},
|
||||
}
|
||||
|
||||
WEBSITE_PREFIX = "permissions.websites:"
|
||||
PROTOCOL_PREFIX = "permissions.protocols:"
|
||||
|
||||
|
||||
def _check_node(node):
|
||||
if node not in VALID_NODES:
|
||||
raise MenuError("unknown node: %r (valid: %s)"
|
||||
% (node, ", ".join(VALID_NODES)))
|
||||
|
||||
|
||||
def normalize_onoff(value):
|
||||
"""on/off/true/false/1/0/yes/no -> bool. None when invalid."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
v = value.strip().lower()
|
||||
if v in ("on", "true", "1", "yes"):
|
||||
return True
|
||||
if v in ("off", "false", "0", "no"):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def resolve_toggle(name):
|
||||
"""Resolve a toggle address to its spec. Raises MenuError."""
|
||||
if not isinstance(name, str) or not name:
|
||||
raise MenuError("toggle name must be a non-empty string")
|
||||
if name in TOGGLES:
|
||||
spec = dict(TOGGLES[name])
|
||||
spec["name"] = name
|
||||
return spec
|
||||
if name.startswith(WEBSITE_PREFIX) and len(name) > len(WEBSITE_PREFIX):
|
||||
return {"name": name, "tab": "Permissions", "kind": "website",
|
||||
"host": name[len(WEBSITE_PREFIX):],
|
||||
"values": _PERM.WEBSITE_MODES}
|
||||
if name.startswith(PROTOCOL_PREFIX) and len(name) > len(PROTOCOL_PREFIX):
|
||||
label = _PERM.resolve_protocol(name[len(PROTOCOL_PREFIX):])
|
||||
if label is None:
|
||||
raise MenuError("unknown protocol: %r (see list_toggles)"
|
||||
% (name,))
|
||||
return {"name": name, "tab": "Permissions", "kind": "protocol",
|
||||
"label": label, "values": ("on", "off")}
|
||||
raise MenuError("unknown toggle: %r (see list_toggles)" % (name,))
|
||||
|
||||
|
||||
def _session(node):
|
||||
_check_node(node)
|
||||
try:
|
||||
ws, _ = get_cdp_ws(node)
|
||||
except Exception as e:
|
||||
raise MenuError("CDP unreachable for %s: %s" % (node, e))
|
||||
return ws
|
||||
|
||||
|
||||
def _normalize_value(spec, value):
|
||||
kind = spec["kind"]
|
||||
if kind == "website":
|
||||
if not isinstance(value, str):
|
||||
raise MenuError("mode must be one of %s"
|
||||
% (spec["values"],))
|
||||
for m in spec["values"]:
|
||||
if value.strip().lower() == m.lower():
|
||||
return m
|
||||
raise MenuError("mode must be one of %s (got %r)"
|
||||
% (spec["values"], value))
|
||||
if kind in ("switch", "adv-switch", "protocol"):
|
||||
b = normalize_onoff(value)
|
||||
if b is None:
|
||||
raise MenuError("value must be on/off (got %r)" % (value,))
|
||||
return "on" if b else "off"
|
||||
if kind == "radio-heading":
|
||||
if value not in spec["values"]:
|
||||
raise MenuError("%s must be one of %s (got %r)"
|
||||
% (spec["name"], spec["values"], value))
|
||||
return value
|
||||
if kind == "radio-aria":
|
||||
if not isinstance(value, str):
|
||||
raise MenuError("theme must be one of %s" % (spec["values"],))
|
||||
v = value.strip().lower()
|
||||
if v in spec["values"]:
|
||||
return v
|
||||
raise MenuError("theme must be one of %s (got %r)"
|
||||
% (spec["values"], value))
|
||||
raise MenuError("cannot set kind %r" % (kind,))
|
||||
|
||||
|
||||
def get_toggle(node, name):
|
||||
"""Read one toggle. Returns {"ok", "node", "toggle", "value"}."""
|
||||
spec = resolve_toggle(name)
|
||||
_check_node(node)
|
||||
try:
|
||||
ws = _session(node)
|
||||
except MenuError as e:
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": str(e)}
|
||||
try:
|
||||
if not dialog.open_settings(ws):
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "settings dialog did not open"}
|
||||
if not dialog.goto_tab(ws, spec["tab"]):
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "tab did not open: %s" % spec["tab"]}
|
||||
kind = spec["kind"]
|
||||
if kind == "radio-heading":
|
||||
vals = {r["value"]: r["checked"]
|
||||
for r in controls.list_radios(ws)
|
||||
if (r.get("heading") or "").lower()
|
||||
== spec["heading"].lower()}
|
||||
on = [v for v, c in vals.items() if c]
|
||||
value = on[0] if len(on) == 1 else None
|
||||
elif kind == "radio-aria":
|
||||
vals = [(r.get("value"), r.get("checked"))
|
||||
for r in controls.list_radios(ws)]
|
||||
on = [v for v, c in vals if c]
|
||||
value = on[0] if len(on) == 1 else None
|
||||
elif kind == "switch":
|
||||
state = _DC.ai_improvement(ws)
|
||||
value = ("on" if state else "off") if state is not None \
|
||||
else None
|
||||
elif kind == "adv-switch":
|
||||
_PERM.ensure_advanced(ws)
|
||||
state = controls.switch_state(ws, spec["label"])
|
||||
value = ("on" if state else "off") if state is not None \
|
||||
else None
|
||||
elif kind == "website":
|
||||
value = _PERM.website_mode(ws, spec["host"])
|
||||
elif kind == "protocol":
|
||||
value = _PERM.protocol_state(ws, spec["label"])
|
||||
else:
|
||||
value = None
|
||||
if value is None:
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "toggle not readable (site changed?)"}
|
||||
return {"ok": True, "node": node, "toggle": name, "value": value}
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "%s: %s" % (type(e).__name__, e)}
|
||||
finally:
|
||||
escape(ws)
|
||||
close(ws)
|
||||
|
||||
|
||||
def set_toggle(node, name, value):
|
||||
"""Set one toggle with readback. Never partially reports success."""
|
||||
spec = resolve_toggle(name)
|
||||
want = _normalize_value(spec, value)
|
||||
_check_node(node)
|
||||
try:
|
||||
ws = _session(node)
|
||||
except MenuError as e:
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": str(e)}
|
||||
try:
|
||||
if not dialog.open_settings(ws):
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "settings dialog did not open"}
|
||||
if not dialog.goto_tab(ws, spec["tab"]):
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "tab did not open: %s" % spec["tab"]}
|
||||
kind = spec["kind"]
|
||||
if kind == "radio-heading":
|
||||
ok = controls.set_radio_by_heading(ws, spec["heading"], want)
|
||||
elif kind == "radio-aria":
|
||||
ok = controls.set_radio_by_aria(ws, want)
|
||||
elif kind == "switch":
|
||||
ok = _DC.set_ai_improvement(ws, want == "on")
|
||||
elif kind == "adv-switch":
|
||||
_PERM.ensure_advanced(ws)
|
||||
ok = controls.set_switch(ws, spec["label"], want == "on")
|
||||
elif kind == "website":
|
||||
ok = _PERM.set_website_mode(ws, spec["host"], want)
|
||||
elif kind == "protocol":
|
||||
ok = _PERM.set_protocol(ws, spec["label"], want == "on")
|
||||
else:
|
||||
ok = False
|
||||
if not ok:
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "set failed verification (site changed?)"}
|
||||
readback = get_toggle(node, name)
|
||||
if not readback.get("ok") or readback.get("value") != want:
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "readback mismatch (want %r, got %r)"
|
||||
% (want, readback.get("value"))}
|
||||
return {"ok": True, "node": node, "toggle": name, "value": want}
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "%s: %s" % (type(e).__name__, e)}
|
||||
finally:
|
||||
escape(ws)
|
||||
close(ws)
|
||||
|
||||
|
||||
def list_toggles(node, tab=None):
|
||||
"""All toggle states, optionally filtered to one tab."""
|
||||
_check_node(node)
|
||||
if tab is not None and tab not in TAB_MODULES:
|
||||
raise MenuError("unknown tab: %r (valid: %s)"
|
||||
% (tab, sorted(TAB_MODULES)))
|
||||
want = [tab] if tab else ["Permissions", "Data controls", "General"]
|
||||
try:
|
||||
ws = _session(node)
|
||||
except MenuError as e:
|
||||
return {"ok": False, "node": node, "error": str(e)}
|
||||
try:
|
||||
if not dialog.open_settings(ws):
|
||||
return {"ok": False, "node": node,
|
||||
"error": "settings dialog did not open"}
|
||||
out = {}
|
||||
if "Permissions" in want:
|
||||
dialog.goto_tab(ws, "Permissions")
|
||||
out.update(_PERM.all_toggles(ws))
|
||||
if "Data controls" in want:
|
||||
state = _DC.ai_improvement(ws)
|
||||
out["data_controls.ai_improvement"] = \
|
||||
("on" if state else "off") if state is not None else None
|
||||
if "General" in want:
|
||||
dialog.goto_tab(ws, "General")
|
||||
out["general.theme"] = _GEN.theme(ws)
|
||||
return {"ok": True, "node": node, "toggles": out}
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node,
|
||||
"error": "%s: %s" % (type(e).__name__, e)}
|
||||
finally:
|
||||
escape(ws)
|
||||
close(ws)
|
||||
|
||||
|
||||
def describe_tab(node, tab):
|
||||
"""Full inventory of one tab (debugging/patching aid)."""
|
||||
_check_node(node)
|
||||
if tab not in TAB_MODULES:
|
||||
raise MenuError("unknown tab: %r (valid: %s)"
|
||||
% (tab, sorted(TAB_MODULES)))
|
||||
try:
|
||||
ws = _session(node)
|
||||
except MenuError as e:
|
||||
return {"ok": False, "node": node, "tab": tab, "error": str(e)}
|
||||
try:
|
||||
if not dialog.open_settings(ws):
|
||||
return {"ok": False, "node": node, "tab": tab,
|
||||
"error": "settings dialog did not open"}
|
||||
if not dialog.goto_tab(ws, tab):
|
||||
return {"ok": False, "node": node, "tab": tab,
|
||||
"error": "tab did not open"}
|
||||
return {"ok": True, "node": node, "tab": tab,
|
||||
"inventory": TAB_MODULES[tab].describe(ws)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "tab": tab,
|
||||
"error": "%s: %s" % (type(e).__name__, e)}
|
||||
finally:
|
||||
escape(ws)
|
||||
close(ws)
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""invite.py — Muse.ai invite codes + usage via the agent browsers.
|
||||
|
||||
Find: GET /api/hatch/invite via in-page fetch (primary; proven live),
|
||||
Invite-button popover parse (DOM fallback).
|
||||
Redeem: POST /api/hatch/invite-code/redeem via in-page fetch.
|
||||
Usage: Settings > General DOM read (weekly reset, % used, additional
|
||||
tokens) through the hatch_menu tree (dialog/mouse/tabs);
|
||||
settings-nav primitives live there, this module keeps the
|
||||
invite API + popover flows and the flat CLI parse.
|
||||
|
||||
Server eligibility (observed live): one redemption per account
|
||||
(already_redeemed); 48h window from joining (window_expired); codes
|
||||
carry limited uses (used_up) and can be revoked. Inviter rewards
|
||||
accrue regardless of the inviter's own redemption state.
|
||||
|
||||
Caller errors (unknown node, malformed code) raise InviteError before
|
||||
any CDP traffic. Transport/CDP failures return {"ok": False, ...}.
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
from approvals import VALID_NODES, cdp_evaluate, get_cdp_ws
|
||||
from hatch_menu import dialog as menu_dialog
|
||||
from hatch_menu.mouse import close as _close
|
||||
from hatch_menu.mouse import escape as _escape
|
||||
from hatch_menu.tabs import general as general_tab
|
||||
|
||||
|
||||
class InviteError(ValueError):
|
||||
"""Caller error: unknown node or malformed code. Raised before CDP."""
|
||||
|
||||
|
||||
CODE_RE = re.compile(r"^[A-Z0-9]{6}$")
|
||||
CODE_REVEALED_RE = re.compile(r"Invite code revealed:\s*([A-Z0-9]{6})")
|
||||
|
||||
JS_INVITE_GET = """(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/hatch/invite',
|
||||
{method: 'GET', cache: 'no-store'});
|
||||
return {status: r.status, data: await r.json()};
|
||||
} catch (e) { return {error: String(e).slice(0, 200)}; }
|
||||
})()"""
|
||||
|
||||
# %s is a validated [A-Z0-9]{6} code: injection-safe by construction.
|
||||
JS_REDEEM_TMPL = """(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/hatch/invite-code/redeem', {method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({code: '%s', supportsRedemptionStatus: true})});
|
||||
return {status: r.status, ok: r.ok, data: await r.json()};
|
||||
} catch (e) { return {error: String(e).slice(0, 200)}; }
|
||||
})()"""
|
||||
|
||||
JS_INVITE_CLICK = ("(() => { const b = document.querySelector("
|
||||
"'[data-testid=\"hatch-invite-friends-button\"]');"
|
||||
" if (!b) return 'NO_BUTTON'; b.click();"
|
||||
" return 'CLICKED'; })()")
|
||||
|
||||
JS_POPOVER_TEXT = ("(() => { const d = document.querySelector("
|
||||
"'[data-slot=\"popover-content\"]');"
|
||||
" return d ? d.innerText : null; })()")
|
||||
|
||||
def normalize_code(code):
|
||||
"""Uppercase/strip a code; None unless 6-char A-Z0-9."""
|
||||
if not isinstance(code, str):
|
||||
return None
|
||||
c = code.strip().upper()
|
||||
return c if CODE_RE.fullmatch(c) else None
|
||||
|
||||
|
||||
def _check_node(node):
|
||||
if node not in VALID_NODES:
|
||||
raise InviteError("unknown node: %r (valid: %s)"
|
||||
% (node, ", ".join(VALID_NODES)))
|
||||
|
||||
|
||||
def open_settings(ws):
|
||||
"""Open the Settings dialog via the dock menu. True when open.
|
||||
|
||||
Public shim over hatch_menu.dialog.open_settings (single copy).
|
||||
"""
|
||||
return menu_dialog.open_settings(ws)
|
||||
|
||||
|
||||
def click_settings_tab(ws, name):
|
||||
"""Open a Settings dialog tab by visible name. True when open.
|
||||
|
||||
Public shim over hatch_menu.dialog.goto_tab (single copy).
|
||||
"""
|
||||
return menu_dialog.goto_tab(ws, name)
|
||||
|
||||
|
||||
def _popover_code(ws):
|
||||
"""Invite code via the main-chat popover (DOM fallback). None if absent."""
|
||||
try:
|
||||
if cdp_evaluate(ws, JS_INVITE_CLICK, timeout=5.0) != "CLICKED":
|
||||
return None
|
||||
time.sleep(1.0)
|
||||
text = cdp_evaluate(ws, JS_POPOVER_TEXT, timeout=5.0) or ""
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
_escape(ws)
|
||||
m = CODE_REVEALED_RE.search(text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def get_invite(node, timeout=8.0):
|
||||
"""Per-agent invite state. API first, popover fallback for the code."""
|
||||
_check_node(node)
|
||||
try:
|
||||
ws, _ = get_cdp_ws(node)
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node,
|
||||
"error": "CDP unreachable: %s" % e}
|
||||
try:
|
||||
try:
|
||||
res = cdp_evaluate(ws, JS_INVITE_GET, await_promise=True,
|
||||
timeout=timeout)
|
||||
except Exception as e:
|
||||
res = {"error": "%s: %s" % (type(e).__name__, e)}
|
||||
if isinstance(res, dict) and res.get("status") == 200 \
|
||||
and isinstance(res.get("data"), dict) \
|
||||
and res["data"].get("code"):
|
||||
d = res["data"]
|
||||
return {"ok": True, "node": node, "source": "api",
|
||||
"code": d.get("code"),
|
||||
"has_redeemed": d.get("has_redeemed_invite_code"),
|
||||
"uses_remaining": d.get("uses_remaining"),
|
||||
"use_count": d.get("use_count"),
|
||||
"reward": d.get("reward")}
|
||||
code = _popover_code(ws)
|
||||
if code:
|
||||
return {"ok": True, "node": node, "source": "dom",
|
||||
"code": code, "has_redeemed": None,
|
||||
"uses_remaining": None, "use_count": None,
|
||||
"reward": None}
|
||||
if isinstance(res, dict):
|
||||
detail = res.get("error", "invite API failed")
|
||||
else:
|
||||
detail = "invite API failed"
|
||||
return {"ok": False, "node": node,
|
||||
"error": "invite lookup failed (%s); popover has no code"
|
||||
% detail}
|
||||
finally:
|
||||
_close(ws)
|
||||
|
||||
|
||||
def redeem_invite(node, code, timeout=12.0):
|
||||
"""Redeem CODE on node. Returns ok / server reason + detail."""
|
||||
c = normalize_code(code)
|
||||
if c is None:
|
||||
raise InviteError("malformed invite code: %r (want 6 chars A-Z0-9)"
|
||||
% (code,))
|
||||
_check_node(node)
|
||||
js = JS_REDEEM_TMPL % c
|
||||
try:
|
||||
ws, _ = get_cdp_ws(node)
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "code": c,
|
||||
"error": "CDP unreachable: %s" % e}
|
||||
try:
|
||||
try:
|
||||
res = cdp_evaluate(ws, js, await_promise=True, timeout=timeout)
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "code": c,
|
||||
"error": "%s: %s" % (type(e).__name__, e)}
|
||||
if not isinstance(res, dict) or "data" not in res:
|
||||
if isinstance(res, dict):
|
||||
detail = res.get("error", "empty redeem response")
|
||||
else:
|
||||
detail = "empty redeem response"
|
||||
return {"ok": False, "node": node, "code": c,
|
||||
"reason": "unknown", "detail": detail}
|
||||
data = res.get("data") or {}
|
||||
if res.get("ok") and data.get("success"):
|
||||
return {"ok": True, "node": node, "code": c,
|
||||
"redemption_status": data.get("redemptionStatus"),
|
||||
"detail": data.get("detail")}
|
||||
return {"ok": False, "node": node, "code": c,
|
||||
"reason": data.get("reason", "unknown"),
|
||||
"detail": data.get("detail")}
|
||||
finally:
|
||||
_close(ws)
|
||||
|
||||
|
||||
def parse_usage_text(text):
|
||||
"""Flat usage parse (CLI contract) over the tree's General parser."""
|
||||
nested = general_tab.parse_usage_text(text)
|
||||
wk = (nested.get("weekly") if nested else None) or {}
|
||||
add = (nested.get("additional") if nested else None) or {}
|
||||
tokens = add.get("tokens_left")
|
||||
return {
|
||||
"weekly_reset": wk.get("resets_on"),
|
||||
"weekly_used_pct": wk.get("pct_used"),
|
||||
"additional_expires": "Never expires"
|
||||
if add.get("never_expires") else None,
|
||||
"additional_used_pct": add.get("pct_used"),
|
||||
"additional_left": ("%s tokens left" % tokens)
|
||||
if tokens else None,
|
||||
"has_redeemed": bool(add.get("never_expires") or tokens
|
||||
or "additional tokens" in (text or "").lower()),
|
||||
}
|
||||
|
||||
|
||||
def get_usage(node, timeout=8.0):
|
||||
"""Usage limits for one agent via Settings General (DOM read)."""
|
||||
_check_node(node)
|
||||
try:
|
||||
ws, _ = get_cdp_ws(node)
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node,
|
||||
"error": "CDP unreachable: %s" % e}
|
||||
try:
|
||||
if not open_settings(ws):
|
||||
return {"ok": False, "node": node,
|
||||
"error": "settings dialog did not open"}
|
||||
if not menu_dialog.goto_tab(ws, "General"):
|
||||
return {"ok": False, "node": node,
|
||||
"error": "General tab did not open"}
|
||||
# Note: Usage stats and redeem field can load asynchronously in the React/Radix tree.
|
||||
# Poll up to timeout seconds for usage or redeem markers.
|
||||
text = None
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
t = menu_dialog.dialog_text(ws)
|
||||
if t and ("Weekly limit" in t or "Additional tokens" in t or "Redeem invite code" in t or "tokens left" in t or "% used" in t):
|
||||
text = t
|
||||
break
|
||||
time.sleep(0.4)
|
||||
if not text:
|
||||
text = menu_dialog.dialog_text(ws)
|
||||
|
||||
if not text:
|
||||
return {"ok": False, "node": node,
|
||||
"error": "empty settings dialog"}
|
||||
out = {"ok": True, "node": node}
|
||||
out.update(parse_usage_text(text))
|
||||
return out
|
||||
finally:
|
||||
_escape(ws)
|
||||
_close(ws)
|
||||
|
||||
|
||||
def fleet_invite_status(nodes=None):
|
||||
"""Invite state per node. Never raises; per-node error dicts."""
|
||||
out = {}
|
||||
for n in (nodes or list(VALID_NODES)):
|
||||
try:
|
||||
out[n] = get_invite(n)
|
||||
except InviteError as e:
|
||||
out[n] = {"ok": False, "node": n, "error": str(e)}
|
||||
return out
|
||||
|
||||
|
||||
def fleet_usage(nodes=None):
|
||||
"""Usage limits per node. Never raises; per-node error dicts."""
|
||||
out = {}
|
||||
for n in (nodes or list(VALID_NODES)):
|
||||
try:
|
||||
out[n] = get_usage(n)
|
||||
except InviteError as e:
|
||||
out[n] = {"ok": False, "node": n, "error": str(e)}
|
||||
return out
|
||||
Executable
+597
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env python3
|
||||
"""invite_handler.py: Invite code discovery, inspection, and redemption handler.
|
||||
|
||||
Supports:
|
||||
- Extracting agent invite codes via Main Chat DOM RPA & in-session API
|
||||
- Redeeming invite codes via Settings Menu RPA & redemption endpoint
|
||||
- Fleet-wide invite inventory, usage tracking, and agent salvage flows
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from approvals import cdp_evaluate, get_cdp_ws, get_node_pages
|
||||
from settings_rpa import SettingsRPA, cdp_click_element_by_selector, cdp_send_escape
|
||||
except ImportError:
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from approvals import cdp_evaluate, get_cdp_ws, get_node_pages
|
||||
from settings_rpa import SettingsRPA, cdp_click_element_by_selector, cdp_send_escape
|
||||
|
||||
|
||||
VALID_NODES = ["muse", "pip", "646", "opm", "def", "dev"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class InviteCodeInfo:
|
||||
node: str
|
||||
code: str
|
||||
uses_remaining: int
|
||||
use_count: int
|
||||
max_uses: int
|
||||
has_redeemed: bool
|
||||
invite_state: str
|
||||
reward: Optional[Dict[str, Any]]
|
||||
method_used: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedemptionResult:
|
||||
target_node: str
|
||||
code: str
|
||||
success: bool
|
||||
status: str
|
||||
reason: Optional[str]
|
||||
detail: Optional[str]
|
||||
method_used: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class InviteHandler:
|
||||
"""Handles invite codes discovery, inspection, and redemption for NetVM nodes."""
|
||||
|
||||
def __init__(self, node: str, timeout: float = 4.0):
|
||||
self.node = node
|
||||
self.timeout = timeout
|
||||
self.ws = None
|
||||
|
||||
def connect(self) -> InviteHandler:
|
||||
if self.ws is None:
|
||||
self.ws, _ = get_cdp_ws(self.node, timeout=self.timeout)
|
||||
return self
|
||||
|
||||
def close(self) -> None:
|
||||
if self.ws is not None:
|
||||
try:
|
||||
self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
def __enter__(self) -> InviteHandler:
|
||||
return self.connect()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.close()
|
||||
|
||||
def find_code_api(self) -> InviteCodeInfo:
|
||||
"""Fetch invite code and metadata directly via in-session /api/hatch/invite query."""
|
||||
self.connect()
|
||||
js_query = """(async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/hatch/invite', {method: 'GET', cache: 'no-store'});
|
||||
if (!resp.ok) return {error: `HTTP ${resp.status}`};
|
||||
return await resp.json();
|
||||
} catch(e) {
|
||||
return {error: e.toString()};
|
||||
}
|
||||
})()"""
|
||||
res = cdp_evaluate(self.ws, js_query, await_promise=True, timeout=self.timeout)
|
||||
if not res or "error" in res or "code" not in res:
|
||||
err = res.get("error", "Invalid response") if res else "No response"
|
||||
raise RuntimeError(f"Failed to fetch invite info for {self.node}: {err}")
|
||||
|
||||
return InviteCodeInfo(
|
||||
node=self.node,
|
||||
code=res.get("code", ""),
|
||||
uses_remaining=res.get("uses_remaining", 0),
|
||||
use_count=res.get("use_count", 0),
|
||||
max_uses=res.get("max_uses", 30),
|
||||
has_redeemed=bool(res.get("has_redeemed_invite_code", False)),
|
||||
invite_state=res.get("invite_state", "UNKNOWN"),
|
||||
reward=res.get("reward"),
|
||||
method_used="api",
|
||||
)
|
||||
|
||||
def find_code_dom(self) -> InviteCodeInfo:
|
||||
"""Find invite code by navigating to Main Chat and opening the Invite popover."""
|
||||
self.connect()
|
||||
# 1. Ensure main chat home is active
|
||||
with SettingsRPA(self.node, timeout=self.timeout) as rpa:
|
||||
rpa.ensure_main_chat()
|
||||
|
||||
time.sleep(0.3)
|
||||
# Dismiss any open popovers first
|
||||
cdp_send_escape(self.ws)
|
||||
time.sleep(0.2)
|
||||
|
||||
# 2. Click Invite button in main chat
|
||||
clicked = cdp_click_element_by_selector(self.ws, '[data-testid="hatch-invite-friends-button"]', timeout=self.timeout)
|
||||
if not clicked:
|
||||
# Fallback to button by text
|
||||
js_click = """(() => {
|
||||
const btn = Array.from(document.querySelectorAll('button')).find(b => (b.innerText||'').trim() === 'Invite');
|
||||
if (btn) { btn.click(); return true; }
|
||||
return false;
|
||||
})()"""
|
||||
clicked = cdp_evaluate(self.ws, js_click)
|
||||
|
||||
if not clicked:
|
||||
raise RuntimeError(f"Could not click Invite button on {self.node}")
|
||||
|
||||
time.sleep(0.8)
|
||||
|
||||
# 3. Read popover contents
|
||||
js_read_popover = """(() => {
|
||||
const pop = document.querySelector('[data-slot="popover-content"]');
|
||||
if (!pop) return {found: false};
|
||||
|
||||
// Look for revealed code span
|
||||
const codeSpan = pop.querySelector('[data-pel-impression="invite_code_revealed_impression"]');
|
||||
let code = codeSpan ? codeSpan.innerText.trim() : null;
|
||||
|
||||
// Regex fallback on text
|
||||
const fullText = pop.innerText || '';
|
||||
if (!code) {
|
||||
const m = fullText.match(/Invite code revealed:\\s*([A-Z0-9]{6})/);
|
||||
if (m) code = m[1];
|
||||
}
|
||||
if (!code) {
|
||||
const m2 = fullText.match(/\\b([A-Z0-9]{6})\\b/);
|
||||
if (m2) code = m2[1];
|
||||
}
|
||||
|
||||
let usesLeft = 30;
|
||||
const mUses = fullText.match(/(\\d+)\\s+uses\\s+left/);
|
||||
if (mUses) usesLeft = parseInt(mUses[1], 10);
|
||||
|
||||
return {
|
||||
found: true,
|
||||
code: code,
|
||||
uses_left: usesLeft,
|
||||
text: fullText
|
||||
};
|
||||
})()"""
|
||||
pop_res = cdp_evaluate(self.ws, js_read_popover)
|
||||
cdp_send_escape(self.ws)
|
||||
|
||||
if not pop_res or not pop_res.get("found") or not pop_res.get("code"):
|
||||
# Fall back to API if popover extraction failed
|
||||
return self.find_code_api()
|
||||
|
||||
code = pop_res.get("code")
|
||||
uses_left = pop_res.get("uses_left", 30)
|
||||
|
||||
# Query API for additional metadata
|
||||
try:
|
||||
api_info = self.find_code_api()
|
||||
api_info.method_used = "dom+api"
|
||||
return api_info
|
||||
except Exception:
|
||||
return InviteCodeInfo(
|
||||
node=self.node,
|
||||
code=code,
|
||||
uses_remaining=uses_left,
|
||||
use_count=30 - uses_left,
|
||||
max_uses=30,
|
||||
has_redeemed=True,
|
||||
invite_state="ELIGIBLE",
|
||||
reward=None,
|
||||
method_used="dom",
|
||||
)
|
||||
|
||||
def find_code(self, method: str = "auto") -> InviteCodeInfo:
|
||||
"""Find invite code using specified method ('auto', 'api', or 'dom')."""
|
||||
if method == "api":
|
||||
return self.find_code_api()
|
||||
elif method == "dom":
|
||||
return self.find_code_dom()
|
||||
else:
|
||||
# Auto: Try API first, fallback to DOM
|
||||
try:
|
||||
return self.find_code_api()
|
||||
except Exception:
|
||||
return self.find_code_dom()
|
||||
|
||||
def redeem_code_api(self, code: str) -> RedemptionResult:
|
||||
"""Redeem invite code using in-session /api/hatch/invite-code/redeem endpoint."""
|
||||
self.connect()
|
||||
clean_code = code.strip().upper()
|
||||
js_redeem = f"""(async () => {{
|
||||
try {{
|
||||
const resp = await fetch('/api/hatch/invite-code/redeem', {{
|
||||
method: 'POST',
|
||||
headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify({{code: {json.dumps(clean_code)}, supportsRedemptionStatus: true}})
|
||||
}});
|
||||
const data = await resp.json();
|
||||
return {{status_code: resp.status, ok: resp.ok, data: data}};
|
||||
}} catch(e) {{
|
||||
return {{error: e.toString()}};
|
||||
}}
|
||||
}})()"""
|
||||
res = cdp_evaluate(self.ws, js_redeem, await_promise=True, timeout=self.timeout)
|
||||
if not res or "error" in res:
|
||||
err = res.get("error", "No response") if res else "Timeout"
|
||||
return RedemptionResult(
|
||||
target_node=self.node,
|
||||
code=clean_code,
|
||||
success=False,
|
||||
status="error",
|
||||
reason="transport_failure",
|
||||
detail=err,
|
||||
method_used="api",
|
||||
)
|
||||
|
||||
data = res.get("data", {})
|
||||
ok = res.get("ok", False) and data.get("success", False)
|
||||
status = data.get("redemptionStatus") or ("redeemed" if ok else "failed")
|
||||
reason = data.get("reason")
|
||||
detail = data.get("detail")
|
||||
|
||||
return RedemptionResult(
|
||||
target_node=self.node,
|
||||
code=clean_code,
|
||||
success=ok,
|
||||
status=status,
|
||||
reason=reason,
|
||||
detail=detail,
|
||||
method_used="api",
|
||||
)
|
||||
|
||||
def redeem_code_dom(self, code: str) -> RedemptionResult:
|
||||
"""Redeem invite code using Settings Menu RPA -> Settings -> Redeem Invite Code dialog."""
|
||||
self.connect()
|
||||
clean_code = code.strip().upper()
|
||||
with SettingsRPA(self.node, timeout=self.timeout) as rpa:
|
||||
# 1. Open Settings dialog
|
||||
opened = rpa.open_settings_dialog()
|
||||
if not opened:
|
||||
return RedemptionResult(
|
||||
target_node=self.node,
|
||||
code=clean_code,
|
||||
success=False,
|
||||
status="error",
|
||||
reason="settings_open_failed",
|
||||
detail="Could not open Settings dialog via RPA",
|
||||
method_used="dom",
|
||||
)
|
||||
|
||||
rpa.select_tab("General")
|
||||
time.sleep(0.3)
|
||||
|
||||
# 2. Check for "Redeem invite code" entry
|
||||
js_find_and_click = """(() => {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
if (!dialog) return {found: false};
|
||||
const items = Array.from(dialog.querySelectorAll('button, div, span'));
|
||||
const redeemItem = items.find(el => (el.innerText || '').trim() === 'Redeem invite code');
|
||||
if (redeemItem) {
|
||||
redeemItem.click();
|
||||
return {found: true};
|
||||
}
|
||||
return {found: false, text: dialog.innerText};
|
||||
})()"""
|
||||
click_res = cdp_evaluate(self.ws, js_find_and_click)
|
||||
if not click_res or not click_res.get("found"):
|
||||
rpa.close_settings_dialog()
|
||||
# If entrypoint missing, likely already redeemed
|
||||
api_check = self.find_code_api()
|
||||
if api_check.has_redeemed:
|
||||
return RedemptionResult(
|
||||
target_node=self.node,
|
||||
code=clean_code,
|
||||
success=False,
|
||||
status="already_redeemed",
|
||||
reason="already_redeemed",
|
||||
detail="Node has already redeemed an invite code (entrypoint hidden)",
|
||||
method_used="dom",
|
||||
)
|
||||
return RedemptionResult(
|
||||
target_node=self.node,
|
||||
code=clean_code,
|
||||
success=False,
|
||||
status="entrypoint_not_found",
|
||||
reason="not_eligible",
|
||||
detail="Redeem invite code item not present in General settings",
|
||||
method_used="dom",
|
||||
)
|
||||
|
||||
time.sleep(0.8)
|
||||
|
||||
# 3. Handle HatchInviteRedemptionDialog
|
||||
js_input_code = f"""(() => {{
|
||||
// Look for dialog titled "Redeem a code"
|
||||
const input = document.querySelector('input[aria-label="Invite code"]') ||
|
||||
document.querySelector('[role="dialog"] input[type="text"]');
|
||||
if (!input) return {{found_input: false}};
|
||||
|
||||
// Type code into input
|
||||
input.focus();
|
||||
input.value = {json.dumps(clean_code)};
|
||||
input.dispatchEvent(new Event('input', {{bubbles: true}}));
|
||||
input.dispatchEvent(new Event('change', {{bubbles: true}}));
|
||||
|
||||
// Submit via Enter key
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', {{key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true}}));
|
||||
return {{found_input: true}};
|
||||
}})()"""
|
||||
input_res = cdp_evaluate(self.ws, js_input_code)
|
||||
time.sleep(1.5)
|
||||
|
||||
# 4. Check outcome in dialog
|
||||
js_check_outcome = """(() => {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
if (!dialog) return {found: false};
|
||||
const text = dialog.innerText || '';
|
||||
|
||||
if (text.includes('Congratulations') || text.includes('successful')) {
|
||||
return {success: true, status: 'redeemed', detail: text};
|
||||
}
|
||||
if (text.includes('already redeemed')) {
|
||||
return {success: false, status: 'already_redeemed', detail: text};
|
||||
}
|
||||
if (text.includes('no longer valid') || text.includes('invalid')) {
|
||||
return {success: false, status: 'invalid', detail: text};
|
||||
}
|
||||
if (text.includes('used up')) {
|
||||
return {success: false, status: 'used_up', detail: text};
|
||||
}
|
||||
if (text.includes('passed') || text.includes('expired')) {
|
||||
return {success: false, status: 'window_expired', detail: text};
|
||||
}
|
||||
return {status: 'unknown', detail: text};
|
||||
})()"""
|
||||
outcome = cdp_evaluate(self.ws, js_check_outcome)
|
||||
rpa.close_settings_dialog()
|
||||
|
||||
if outcome and outcome.get("success"):
|
||||
return RedemptionResult(
|
||||
target_node=self.node,
|
||||
code=clean_code,
|
||||
success=True,
|
||||
status="redeemed",
|
||||
reason=None,
|
||||
detail="Redemption successful via Settings RPA",
|
||||
method_used="dom",
|
||||
)
|
||||
elif outcome and outcome.get("status") in ["already_redeemed", "invalid", "used_up", "window_expired"]:
|
||||
return RedemptionResult(
|
||||
target_node=self.node,
|
||||
code=clean_code,
|
||||
success=False,
|
||||
status=outcome.get("status"),
|
||||
reason=outcome.get("status"),
|
||||
detail=outcome.get("detail"),
|
||||
method_used="dom",
|
||||
)
|
||||
|
||||
# Fallback to API check if DOM did not confirm status
|
||||
return self.redeem_code_api(clean_code)
|
||||
|
||||
def redeem_code(self, code: str, method: str = "auto") -> RedemptionResult:
|
||||
"""Redeem invite code using specified method ('auto', 'dom', or 'api')."""
|
||||
if method == "dom":
|
||||
return self.redeem_code_dom(code)
|
||||
elif method == "api":
|
||||
return self.redeem_code_api(code)
|
||||
else:
|
||||
# Auto: Validate and attempt via API for reliability
|
||||
return self.redeem_code_api(code)
|
||||
|
||||
|
||||
def scan_fleet_invites(nodes: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""Scan fleet nodes and return invite code details for each active node."""
|
||||
target_nodes = nodes or ["muse", "pip", "646", "opm"]
|
||||
results = []
|
||||
for node in target_nodes:
|
||||
try:
|
||||
handler = InviteHandler(node)
|
||||
with handler:
|
||||
info = handler.find_code(method="auto")
|
||||
results.append(info.to_dict())
|
||||
except Exception as e:
|
||||
results.append({
|
||||
"node": node,
|
||||
"code": None,
|
||||
"uses_remaining": 0,
|
||||
"use_count": 0,
|
||||
"max_uses": 30,
|
||||
"has_redeemed": None,
|
||||
"invite_state": "UNREACHABLE",
|
||||
"reward": None,
|
||||
"method_used": "error",
|
||||
"error": str(e),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def scan_fleet_usage(nodes: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""Scan fleet nodes and return token usage for each active node."""
|
||||
target_nodes = nodes or ["muse", "pip", "646", "opm"]
|
||||
results = []
|
||||
for node in target_nodes:
|
||||
try:
|
||||
with SettingsRPA(node) as rpa:
|
||||
usage = rpa.read_usage()
|
||||
results.append(usage.to_dict())
|
||||
except Exception as e:
|
||||
results.append({
|
||||
"node": node,
|
||||
"plan": "Unknown",
|
||||
"weekly_reset_text": "Unreachable",
|
||||
"weekly_percent_used": 0,
|
||||
"extra_tokens_status": "Unknown",
|
||||
"extra_percent_used": 0,
|
||||
"extra_tokens_remaining": "Unknown",
|
||||
"is_blocked": False,
|
||||
"bars": [],
|
||||
"error": str(e),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def salvage_blocked_node(blocked_node: str = "646", helper_node: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Salvage an out-of-tokens node by identifying its code and redeeming it on an eligible peer."""
|
||||
# 1. Fetch blocked node invite code
|
||||
with InviteHandler(blocked_node) as h_blocked:
|
||||
blocked_info = h_blocked.find_code()
|
||||
|
||||
code_to_redeem = blocked_info.code
|
||||
if not code_to_redeem:
|
||||
return {
|
||||
"success": False,
|
||||
"blocked_node": blocked_node,
|
||||
"error": f"Could not find invite code for blocked node {blocked_node}",
|
||||
}
|
||||
|
||||
# 2. Check candidate helper nodes
|
||||
candidates = [helper_node] if helper_node else [n for n in VALID_NODES if n != blocked_node]
|
||||
eligible_peer = None
|
||||
|
||||
for peer in candidates:
|
||||
try:
|
||||
with InviteHandler(peer) as h_peer:
|
||||
peer_info = h_peer.find_code()
|
||||
if not peer_info.has_redeemed:
|
||||
eligible_peer = peer
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not eligible_peer:
|
||||
return {
|
||||
"success": False,
|
||||
"blocked_node": blocked_node,
|
||||
"invite_code": code_to_redeem,
|
||||
"error": "No existing fleet peer is currently eligible (all active peers have already redeemed an invite code). An onboarding agent or fresh client profile must redeem this code.",
|
||||
"code_to_redeem": code_to_redeem,
|
||||
"share_instruction": f"Redeem code '{code_to_redeem}' on a newly provisioned agent to credit 1 billion tokens to {blocked_node}."
|
||||
}
|
||||
|
||||
# 3. Redeem on eligible peer
|
||||
with InviteHandler(eligible_peer) as h_peer:
|
||||
redemption = h_peer.redeem_code(code_to_redeem)
|
||||
|
||||
return {
|
||||
"success": redemption.success,
|
||||
"blocked_node": blocked_node,
|
||||
"helper_node": eligible_peer,
|
||||
"code_redeemed": code_to_redeem,
|
||||
"redemption_result": redemption.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="NetVM Invite Code Handler")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
p_find = subparsers.add_parser("find", help="Find invite code for an agent")
|
||||
p_find.add_argument("node", help="Node name (e.g. 646, pip, muse, opm)")
|
||||
p_find.add_argument("--method", choices=["auto", "api", "dom"], default="auto")
|
||||
p_find.add_argument("--json", action="store_true")
|
||||
|
||||
p_redeem = subparsers.add_parser("redeem", help="Redeem an invite code on a target agent")
|
||||
p_redeem.add_argument("node", help="Target node to redeem the code on")
|
||||
p_redeem.add_argument("code", help="6-character invite code")
|
||||
p_redeem.add_argument("--method", choices=["auto", "api", "dom"], default="auto")
|
||||
p_redeem.add_argument("--json", action="store_true")
|
||||
|
||||
p_list = subparsers.add_parser("list", help="List invite codes across fleet")
|
||||
p_list.add_argument("--json", action="store_true")
|
||||
|
||||
p_salvage = subparsers.add_parser("salvage", help="Salvage a blocked agent (e.g. 646)")
|
||||
p_salvage.add_argument("node", default="646", nargs="?", help="Blocked node (default: 646)")
|
||||
p_salvage.add_argument("--helper", help="Specific helper node to redeem code")
|
||||
p_salvage.add_argument("--json", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "find":
|
||||
with InviteHandler(args.node) as h:
|
||||
info = h.find_code(method=args.method)
|
||||
if args.json:
|
||||
print(json.dumps(info.to_dict(), indent=2))
|
||||
else:
|
||||
print(f"=== Agent {info.node} Invite Code ===")
|
||||
print(f" Code: {info.code}")
|
||||
print(f" Uses Remaining: {info.uses_remaining} / {info.max_uses}")
|
||||
print(f" Has Redeemed?: {'Yes' if info.has_redeemed else 'No'}")
|
||||
print(f" Method: {info.method_used}")
|
||||
if info.reward:
|
||||
print(f" Reward: {info.reward.get('title')}")
|
||||
|
||||
elif args.command == "redeem":
|
||||
with InviteHandler(args.node) as h:
|
||||
res = h.redeem_code(args.code, method=args.method)
|
||||
if args.json:
|
||||
print(json.dumps(res.to_dict(), indent=2))
|
||||
else:
|
||||
status_icon = "✔" if res.success else "✖"
|
||||
print(f"[{status_icon}] Redemption on {res.target_node} for code {res.code}:")
|
||||
print(f" Success: {res.success}")
|
||||
print(f" Status: {res.status}")
|
||||
if res.detail:
|
||||
print(f" Detail: {res.detail}")
|
||||
|
||||
elif args.command == "list":
|
||||
fleet = scan_fleet_invites()
|
||||
if args.json:
|
||||
print(json.dumps(fleet, indent=2))
|
||||
else:
|
||||
print("\n=== NETVM FLEET INVITE CODES ===")
|
||||
print(f" {'NODE':<8} {'INVITE CODE':<14} {'USES LEFT':<12} {'REDEEMED?':<12} {'REWARD / NOTE':<25}")
|
||||
print(f" {'────':<8} {'───────────':<14} {'─────────':<12} {'─────────':<12} {'─────────────':<25}")
|
||||
for row in fleet:
|
||||
node = row.get("node", "")
|
||||
code = row.get("code") or "N/A"
|
||||
uses = f"{row.get('uses_remaining', 0)}/{row.get('max_uses', 30)}"
|
||||
redeemed = "Yes" if row.get("has_redeemed") else "No"
|
||||
reward = row.get("reward", {})
|
||||
reward_str = reward.get("title", "-") if reward else "-"
|
||||
print(f" {node:<8} {code:<14} {uses:<12} {redeemed:<12} {reward_str:<25}")
|
||||
print()
|
||||
|
||||
elif args.command == "salvage":
|
||||
salvage_res = salvage_blocked_node(args.node, helper_node=args.helper)
|
||||
if args.json:
|
||||
print(json.dumps(salvage_res, indent=2))
|
||||
else:
|
||||
print(f"\n=== SALVAGE REPORT FOR {args.node.upper()} ===")
|
||||
print(f" Invite Code to Credit: {salvage_res.get('invite_code') or salvage_res.get('code_redeemed')}")
|
||||
if salvage_res.get("success"):
|
||||
print(f" Status: SUCCESS! Redeemed on {salvage_res.get('helper_node')}")
|
||||
else:
|
||||
print(f" Status: {salvage_res.get('error')}")
|
||||
if salvage_res.get("share_instruction"):
|
||||
print(f" Next Step: {salvage_res.get('share_instruction')}")
|
||||
print()
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -52,6 +52,7 @@ class InputType(str, Enum):
|
||||
MANUAL = "manual" # human/operator-authored DM
|
||||
HEALTH = "health" # system health check results
|
||||
HEARTBEAT = "heartbeat" # loopback liveness probes
|
||||
SALVAGE = "salvage" # token exhaustion & onboarding work orders
|
||||
|
||||
|
||||
class Priority(str, Enum):
|
||||
@@ -139,6 +140,11 @@ _MODULATION: Dict[Tuple[InputType, Optional[str]], Tuple] = {
|
||||
(InputType.HEALTH, "OK"): (False, Priority.ROUTINE, 0, 0, None),
|
||||
(InputType.HEALTH, None): (True, Priority.IMPORTANT, 1800, 2, "opm"),
|
||||
|
||||
# Salvage: token depletion & onboarding rescue.
|
||||
(InputType.SALVAGE, "BLOCKED"): (True, Priority.CRITICAL, 600, 3, "opm"),
|
||||
(InputType.SALVAGE, "LOW"): (True, Priority.IMPORTANT, 900, 2, "opm"),
|
||||
(InputType.SALVAGE, None): (True, Priority.CRITICAL, 900, 2, "opm"),
|
||||
|
||||
# Heartbeat loopback: NEVER tracked. Hard exclusion.
|
||||
(InputType.HEARTBEAT, None): (False, Priority.ROUTINE, 0, 0, None),
|
||||
}
|
||||
|
||||
Executable
+496
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env python3
|
||||
"""onboard_pipeline.py — End-to-end agent-driven onboarding & invite salvage pipeline.
|
||||
|
||||
Orchestrates the complete lifecycle:
|
||||
1. Identifies the most urgent beneficiary agent in need of tokens (blocked first, then lowest balance).
|
||||
2. Provisions infrastructure (WireGuard, dedicated netns, CDP port, chrome-box profile).
|
||||
3. Launches authentication via cred_client / onboard-driver.
|
||||
4. Waits for / accepts OTP code.
|
||||
5. Checks age verification gate (Tailscale portal / Instagram link).
|
||||
6. Automatically redeems the urgent agent's invite code on the newly onboarded node.
|
||||
7. Injects operational DRIVE and marks the node active.
|
||||
8. Can dispatch cryptographically signed work orders ([WO]) to prompting sidechats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
BIN_DIR = REPO_ROOT / "bin"
|
||||
sys.path.insert(0, str(BIN_DIR))
|
||||
|
||||
try:
|
||||
import invite
|
||||
from cred_client import CredClient
|
||||
from settings_rpa import SettingsRPA
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
STAGE_INFRA = "infra_provisioned"
|
||||
STAGE_INITIATE = "auth_initiated"
|
||||
STAGE_AWAIT_OTP = "awaiting_otp"
|
||||
STAGE_AUTH_ACTIVE = "auth_active"
|
||||
STAGE_REDEEMED = "invite_redeemed"
|
||||
STAGE_DRIVE_INJECTED = "drive_injected"
|
||||
STAGE_COMPLETED = "completed"
|
||||
|
||||
STATE_DIR = Path(os.environ.get("NETVM_ONBOARD_STATE", "/tmp/netvm-onboard"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class OnboardState:
|
||||
node: str
|
||||
email: str
|
||||
beneficiary_node: Optional[str]
|
||||
invite_code: Optional[str]
|
||||
stage: str
|
||||
cdp_port: Optional[int] = None
|
||||
created_at: float = 0.0
|
||||
updated_at: float = 0.0
|
||||
detail: Optional[str] = None
|
||||
redemption_result: Optional[Dict[str, Any]] = None
|
||||
|
||||
def save(self) -> None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = STATE_DIR / f"{self.node}.json"
|
||||
self.updated_at = time.time()
|
||||
with open(path, "w") as f:
|
||||
json.dump(asdict(self), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, node: str) -> Optional[OnboardState]:
|
||||
path = STATE_DIR / f"{node}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
return cls(**data)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Canonical agent operational roles in NetVM
|
||||
AGENT_ROLES: Dict[str, str] = {
|
||||
"646": "Production Lead / Swarm Execution",
|
||||
"opm": "Fleet Coordinator / Loop Orchestrator",
|
||||
"pip": "Production Agent / Pipeline Worker",
|
||||
"muse": "Core Engine / Auditor & Dev",
|
||||
"dev": "Development / Test & Staging",
|
||||
"def": "Defense / Security & Standby",
|
||||
}
|
||||
|
||||
# Base role priority multiplier
|
||||
ROLE_WEIGHT_MULTIPLIER: Dict[str, float] = {
|
||||
"646": 1.5, # Critical production executor
|
||||
"opm": 1.4, # Central coordinator & orchestrator
|
||||
"pip": 1.2, # High volume production worker
|
||||
"muse": 1.1, # Auditor & core engine
|
||||
"dev": 0.8, # Test & staging
|
||||
"def": 0.7, # Defense & standby
|
||||
}
|
||||
|
||||
|
||||
def get_agent_metrics() -> Tuple[Dict[str, int], Dict[str, int]]:
|
||||
"""Count job assignments and cumulative chat activity (work done over time) per agent."""
|
||||
from collections import Counter
|
||||
job_counts = Counter()
|
||||
jobs_dir = REPO_ROOT / "jobs"
|
||||
if jobs_dir.exists():
|
||||
for p in jobs_dir.glob("*.json"):
|
||||
try:
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
a = d.get("target_agent") or d.get("agent")
|
||||
if a:
|
||||
job_counts[a] += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
msg_counts = Counter()
|
||||
chat_log = REPO_ROOT / "logs" / "chat-history.jsonl"
|
||||
if chat_log.exists():
|
||||
try:
|
||||
with open(chat_log, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
try:
|
||||
row = json.loads(line)
|
||||
s = row.get("sender") or row.get("from") or row.get("agent")
|
||||
if s:
|
||||
msg_counts[s] += 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return dict(job_counts), dict(msg_counts)
|
||||
|
||||
|
||||
def calculate_feeding_weights(nodes: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""Compute feeding weights for all agents considering:
|
||||
- Urgency: Blocked (highest), weekly used %, token depletion
|
||||
- Work done over time: Messages processed / chat history volume (strongest operational weight)
|
||||
- Job amount: Active and assigned jobs in registry
|
||||
- Role importance: Multiplier based on operational criticality
|
||||
"""
|
||||
target_nodes = nodes or ["646", "opm", "pip", "muse", "dev", "def"]
|
||||
job_counts, msg_counts = get_agent_metrics()
|
||||
usage_map = invite.fleet_usage(target_nodes)
|
||||
invites_map = invite.fleet_invite_status(target_nodes)
|
||||
|
||||
rows = []
|
||||
for n in target_nodes:
|
||||
u = usage_map.get(n, {})
|
||||
inv = invites_map.get(n, {})
|
||||
code = inv.get("code") or "-"
|
||||
|
||||
wu = u.get("weekly_used_pct") or 0
|
||||
extra_left = u.get("additional_left") or "-"
|
||||
extra_used = u.get("additional_used_pct") or 0
|
||||
is_blocked = (wu >= 100 and "0 tokens left" in str(extra_left)) or (wu >= 100 and extra_used >= 100)
|
||||
|
||||
jobs = job_counts.get(n, 0)
|
||||
work_done = msg_counts.get(n, 0)
|
||||
role_desc = AGENT_ROLES.get(n, "Agent Worker")
|
||||
role_mult = ROLE_WEIGHT_MULTIPLIER.get(n, 1.0)
|
||||
|
||||
# Feeding score calculation:
|
||||
# Base: Blocked = 1000 pts; Weekly limit 100% = 300 pts; proportional to weekly used %
|
||||
urgency_score = 1000.0 if is_blocked else (300.0 if wu >= 100 else float(wu))
|
||||
# Work done weight: 1 point per 10 messages (strongest historical work indicator)
|
||||
work_score = float(work_done) * 0.1
|
||||
# Job weight: 2 points per assigned job
|
||||
job_score = float(jobs) * 2.0
|
||||
|
||||
# Composite feeding weight
|
||||
feeding_weight = round((urgency_score + work_score + job_score) * role_mult, 1)
|
||||
|
||||
status_label = "BLOCKED" if is_blocked else ("LIMIT_REACHED" if wu >= 100 else "ACTIVE")
|
||||
|
||||
rows.append({
|
||||
"node": n,
|
||||
"role": role_desc,
|
||||
"code": code,
|
||||
"status": status_label,
|
||||
"weekly_used_pct": wu,
|
||||
"additional_left": extra_left,
|
||||
"jobs_count": jobs,
|
||||
"work_done_msgs": work_done,
|
||||
"feeding_weight": feeding_weight,
|
||||
"is_blocked": is_blocked,
|
||||
"invites_left": inv.get("uses_remaining", 30),
|
||||
})
|
||||
|
||||
# Sort descending by feeding weight
|
||||
rows.sort(key=lambda r: r["feeding_weight"], reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def select_urgent_beneficiary(explicit_node: Optional[str] = None, explicit_code: Optional[str] = None) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Determine the optimal beneficiary agent to feed with Muse tokens and return (beneficiary_node, invite_code, reason).
|
||||
|
||||
Priority:
|
||||
1. Explicit code / node requested by caller.
|
||||
2. Highest feeding weight (combining urgency, work done over time, job volume, and role criticality).
|
||||
"""
|
||||
if explicit_code:
|
||||
return explicit_node, explicit_code.strip().upper(), "Explicit invite code provided"
|
||||
|
||||
if explicit_node:
|
||||
try:
|
||||
inv = invite.get_invite(explicit_node)
|
||||
if inv.get("ok") and inv.get("code"):
|
||||
return explicit_node, inv["code"], f"Explicit beneficiary agent @{explicit_node}"
|
||||
except Exception as e:
|
||||
return explicit_node, None, f"Failed to fetch invite code for @{explicit_node}: {e}"
|
||||
|
||||
# Calculate weighted rankings across fleet
|
||||
rankings = calculate_feeding_weights()
|
||||
for row in rankings:
|
||||
if row["code"] and row["code"] != "-":
|
||||
n = row["node"]
|
||||
code = row["code"]
|
||||
weight = row["feeding_weight"]
|
||||
st = row["status"]
|
||||
role = row["role"]
|
||||
work = row["work_done_msgs"]
|
||||
reason = f"Top feeding weight: {weight} pts (@{n} [{role}] - status:{st}, work_done:{work} msgs, jobs:{row['jobs_count']})"
|
||||
return n, code, reason
|
||||
|
||||
# Fallback to 646 or muse if available
|
||||
for fallback in ["646", "pip", "muse", "opm"]:
|
||||
try:
|
||||
inv = invite.get_invite(fallback)
|
||||
if inv.get("ok") and inv.get("code"):
|
||||
return fallback, inv["code"], f"Default fleet agent @{fallback}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None, None, "No active agent invite code found"
|
||||
|
||||
|
||||
def provision_node_infra(node: str) -> Dict[str, Any]:
|
||||
"""Execute ./bin/netvm-provision-node.sh <node> to setup netns, wireguard, chrome-box."""
|
||||
script = BIN_DIR / "netvm-provision-node.sh"
|
||||
cmd = [str(script), node]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"node": node,
|
||||
"error": res.stderr.strip() or res.stdout.strip() or f"Exit {res.returncode}",
|
||||
}
|
||||
return {"ok": True, "node": node, "output": res.stdout.strip()}
|
||||
|
||||
|
||||
def start_onboarding(node: str, email: str, beneficiary_node: Optional[str] = None, invite_code: Optional[str] = None, account_name: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Phase 1 & 2: Provision infra, choose beneficiary invite code, and initiate authentication."""
|
||||
b_node, code, reason = select_urgent_beneficiary(beneficiary_node, invite_code)
|
||||
|
||||
state = OnboardState(
|
||||
node=node,
|
||||
email=email,
|
||||
beneficiary_node=b_node,
|
||||
invite_code=code,
|
||||
stage="starting",
|
||||
created_at=time.time(),
|
||||
updated_at=time.time(),
|
||||
detail=reason,
|
||||
)
|
||||
state.save()
|
||||
|
||||
# 1. Provision infra
|
||||
infra_res = provision_node_infra(node)
|
||||
if not infra_res.get("ok"):
|
||||
state.stage = "infra_failed"
|
||||
state.detail = infra_res.get("error")
|
||||
state.save()
|
||||
return {"ok": False, "state": asdict(state), "error": f"Infra provisioning failed: {state.detail}"}
|
||||
|
||||
state.stage = STAGE_INFRA
|
||||
state.save()
|
||||
|
||||
# 2. Initiate authentication
|
||||
client = CredClient()
|
||||
cred_res = client.initiate(node, email, service="muse", account_name=account_name)
|
||||
st = cred_res.get("status")
|
||||
|
||||
if st == "awaiting_otp":
|
||||
state.stage = STAGE_AWAIT_OTP
|
||||
state.detail = f"OTP verification code sent to {email}"
|
||||
state.save()
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "awaiting_otp",
|
||||
"state": asdict(state),
|
||||
"beneficiary_node": b_node,
|
||||
"invite_code_queued": code,
|
||||
"message": f"Verification code sent to {email}. Submit with: box onboard submit-otp --node {node} --otp <code>",
|
||||
}
|
||||
elif st == "active":
|
||||
state.stage = STAGE_AUTH_ACTIVE
|
||||
state.save()
|
||||
# Immediately redeem queued code
|
||||
return finish_onboarding_redemption(state)
|
||||
else:
|
||||
state.stage = "auth_error"
|
||||
state.detail = cred_res.get("detail") or cred_res.get("message")
|
||||
state.save()
|
||||
return {"ok": False, "status": st, "state": asdict(state), "error": state.detail}
|
||||
|
||||
|
||||
def submit_onboarding_otp(node: str, otp: str, email: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Phase 3: Submit transient OTP and proceed to redemption upon success."""
|
||||
state = OnboardState.load(node)
|
||||
if not state:
|
||||
state = OnboardState(
|
||||
node=node,
|
||||
email=email or "unknown",
|
||||
beneficiary_node=None,
|
||||
invite_code=None,
|
||||
stage=STAGE_AWAIT_OTP,
|
||||
created_at=time.time(),
|
||||
)
|
||||
|
||||
client = CredClient()
|
||||
res = client.submit_otp(node, otp, email=state.email or email)
|
||||
st = res.get("status")
|
||||
|
||||
if st == "active":
|
||||
state.stage = STAGE_AUTH_ACTIVE
|
||||
state.detail = "Session authenticated successfully"
|
||||
state.save()
|
||||
return finish_onboarding_redemption(state)
|
||||
else:
|
||||
state.detail = res.get("detail") or res.get("message")
|
||||
state.save()
|
||||
return {"ok": False, "status": st, "state": asdict(state), "error": state.detail}
|
||||
|
||||
|
||||
def finish_onboarding_redemption(state: OnboardState) -> Dict[str, Any]:
|
||||
"""Phase 4 & 5: Redeem queued invite code and inject DRIVE."""
|
||||
# Ensure beneficiary code is present
|
||||
if not state.invite_code:
|
||||
b_node, code, _ = select_urgent_beneficiary(state.beneficiary_node, None)
|
||||
state.beneficiary_node = b_node
|
||||
state.invite_code = code
|
||||
|
||||
redemption_info = None
|
||||
if state.invite_code:
|
||||
try:
|
||||
# Redeem queued code on the fresh node
|
||||
redemption = invite.redeem_invite(state.node, state.invite_code, timeout=20.0)
|
||||
redemption_info = redemption
|
||||
state.redemption_result = redemption
|
||||
if redemption.get("ok"):
|
||||
state.stage = STAGE_REDEEMED
|
||||
state.detail = f"Successfully redeemed code {state.invite_code}! 1B tokens credited to @{state.beneficiary_node} and @{state.node}."
|
||||
else:
|
||||
state.detail = f"Redemption failed: {redemption.get('reason')} - {redemption.get('detail')}"
|
||||
except Exception as e:
|
||||
redemption_info = {"ok": False, "error": str(e)}
|
||||
state.detail = f"Redemption exception: {e}"
|
||||
|
||||
# Phase 5: Inject DRIVE
|
||||
try:
|
||||
drive_script = BIN_DIR / "agent_md.py"
|
||||
if drive_script.exists():
|
||||
subprocess.run([sys.executable, str(drive_script), "inject-drive", state.node], capture_output=True, text=True, timeout=15)
|
||||
state.stage = STAGE_COMPLETED
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
state.save()
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "completed" if (redemption_info and redemption_info.get("ok")) else "auth_active_redemption_warning",
|
||||
"state": asdict(state),
|
||||
"beneficiary_node": state.beneficiary_node,
|
||||
"invite_code": state.invite_code,
|
||||
"redemption": redemption_info,
|
||||
"message": f"Node @{state.node} is fully active! 1B tokens granted.",
|
||||
}
|
||||
|
||||
|
||||
def issue_salvage_work_order(blocked_node: str = "646", to_sidechat: str = "646 tasks") -> Dict[str, Any]:
|
||||
"""Issue a cryptographically signed Work Order prompting fleet operators or agents to initiate onboarding."""
|
||||
inv = invite.get_invite(blocked_node)
|
||||
code = inv.get("code") or "UNKNOWN"
|
||||
title = f"Salvage Blocked Agent @{blocked_node}"
|
||||
body = (
|
||||
f"Agent @{blocked_node} is out of tokens (code: {code}). "
|
||||
f"Initiate client onboarding to grant 1 Billion tokens: box onboard <new_node> --email <client_email> --for {blocked_node}"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"python3", str(BIN_DIR / "super-cli.py"), "dm", "wo",
|
||||
"--to", "opm",
|
||||
"--target", to_sidechat,
|
||||
"--title", title,
|
||||
"--body", body,
|
||||
"--priority", "urgent",
|
||||
"--allow-main-chat"
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return {"ok": res.returncode == 0, "output": res.stdout.strip()}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="End-to-end agent-driven onboarding & invite salvage pipeline")
|
||||
sub = parser.add_subparsers(dest="action")
|
||||
|
||||
p_start = sub.add_parser("start", help="Start full onboarding pipeline for a client node")
|
||||
p_start.add_argument("node", help="New node label (e.g. dev2, client1)")
|
||||
p_start.add_argument("--email", required=True, help="Client login email")
|
||||
p_start.add_argument("--for", dest="for_agent", help="Beneficiary agent to unblock (defaults to most urgent)")
|
||||
p_start.add_argument("--code", help="Explicit 6-character invite code to redeem")
|
||||
p_start.add_argument("--account-name", help="Display name hint")
|
||||
p_start.add_argument("--json", action="store_true", help="Emit JSON output")
|
||||
|
||||
p_otp = sub.add_parser("submit-otp", help="Submit transient OTP verification code")
|
||||
p_otp.add_argument("node", help="Node label")
|
||||
p_otp.add_argument("otp", help="6-digit verification code")
|
||||
p_otp.add_argument("--email", help="Client email (optional)")
|
||||
p_otp.add_argument("--json", action="store_true", help="Emit JSON output")
|
||||
|
||||
p_status = sub.add_parser("status", help="Check onboarding pipeline state for a node")
|
||||
p_status.add_argument("node", help="Node label")
|
||||
p_status.add_argument("--json", action="store_true", help="Emit JSON output")
|
||||
|
||||
p_wo = sub.add_parser("salvage-wo", help="Dispatch salvage work order to sidechat")
|
||||
p_wo.add_argument("node", nargs="?", default="646", help="Blocked agent node (default: 646)")
|
||||
p_wo.add_argument("--target", default="646 tasks", help="Target sidechat")
|
||||
|
||||
p_feed = sub.add_parser("feed-matrix", help="Display all agents ranked by feeding weight, job volume, role, and work done over time")
|
||||
p_feed.add_argument("--json", action="store_true", help="Emit JSON output")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action == "start":
|
||||
res = start_onboarding(args.node, args.email, beneficiary_node=getattr(args, "for_agent", None), invite_code=args.code, account_name=args.account_name)
|
||||
if args.json:
|
||||
print(json.dumps(res, indent=2))
|
||||
elif res.get("ok"):
|
||||
print(f"\n[ONBOARDING STARTED] Node: {args.node}")
|
||||
print(f" Beneficiary: @{res.get('beneficiary_node')} (Invite Code: {res.get('invite_code_queued')})")
|
||||
print(f" Message: {res.get('message')}\n")
|
||||
else:
|
||||
print(f"\n[ERROR] {res.get('error')}\n", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elif args.action == "submit-otp":
|
||||
res = submit_onboarding_otp(args.node, args.otp, email=args.email)
|
||||
if args.json:
|
||||
print(json.dumps(res, indent=2))
|
||||
elif res.get("ok"):
|
||||
print(f"\n[ONBOARDING COMPLETE] {res.get('message')}")
|
||||
print(f" Beneficiary Credited: @{res.get('beneficiary_node')} (+1,000,000,000 tokens)\n")
|
||||
else:
|
||||
print(f"\n[ERROR] {res.get('error')}\n", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elif args.action == "status":
|
||||
state = OnboardState.load(args.node)
|
||||
if not state:
|
||||
print(f"No onboarding pipeline record found for {args.node}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.json:
|
||||
print(json.dumps(asdict(state), indent=2))
|
||||
else:
|
||||
print(f"\n=== ONBOARDING STATUS: @{state.node} ===")
|
||||
print(f" Email: {state.email}")
|
||||
print(f" Stage: {state.stage}")
|
||||
print(f" Beneficiary: @{state.beneficiary_node} (Code: {state.invite_code})")
|
||||
if state.detail:
|
||||
print(f" Detail: {state.detail}")
|
||||
print()
|
||||
|
||||
elif args.action == "salvage-wo":
|
||||
res = issue_salvage_work_order(args.node, to_sidechat=args.target)
|
||||
print(f"Salvage work order dispatched for @{args.node}: ok={res.get('ok')}")
|
||||
|
||||
elif args.action == "feed-matrix":
|
||||
matrix = calculate_feeding_weights()
|
||||
if args.json:
|
||||
print(json.dumps(matrix, indent=2))
|
||||
else:
|
||||
print("\n=== FLEET FEEDING & TOKEN ALLOCATION MATRIX ===")
|
||||
print(" (Weighted by: Urgency + Work Done Over Time + Job Amount + Role Criticality)\n")
|
||||
print(f" {'RANK':4} {'AGENT':6} {'ROLE':38} {'FEED SCORE':11} {'STATUS':10} {'JOBS':6} {'WORK DONE':10} {'CODE':8}")
|
||||
print(f" {'─'*4} {'─'*6} {'─'*38} {'─'*11} {'─'*10} {'─'*6} {'─'*10} {'─'*8}")
|
||||
for idx, r in enumerate(matrix, 1):
|
||||
print(f" #{idx:<3} {r['node']:<6} {r['role']:<38} {r['feeding_weight']:<11} {r['status']:<10} {r['jobs_count']:<6} {str(r['work_done_msgs']) + ' msgs':<10} {r['code']:<8}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+443
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env python3
|
||||
"""settings_rpa.py: RPA module for Muse.ai Settings menu, dock rail, and dialogs.
|
||||
|
||||
Provides headless browser automation primitives for:
|
||||
- Dock rail settings menu toggle via CDP mouse events
|
||||
- Settings dialog navigation (General, Connectors, Wallet, etc.)
|
||||
- Token and weekly quota usage inspection
|
||||
- Settings-based invite code redemption entry point
|
||||
|
||||
Compatible with NetVM's unified box CLI and approvals system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
from approvals import cdp_evaluate, get_cdp_ws, get_node_pages
|
||||
except ImportError:
|
||||
# Allow running when approvals is in current dir or NetVM bin
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from approvals import cdp_evaluate, get_cdp_ws, get_node_pages
|
||||
|
||||
|
||||
_REQ_COUNTER = itertools.count(10000)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageBar:
|
||||
label: str
|
||||
percent: int
|
||||
raw_text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeUsage:
|
||||
node: str
|
||||
plan: str
|
||||
weekly_reset_text: str
|
||||
weekly_percent_used: int
|
||||
extra_tokens_status: str
|
||||
extra_percent_used: int
|
||||
extra_tokens_remaining: str
|
||||
is_blocked: bool
|
||||
bars: List[Dict[str, Any]]
|
||||
dialog_text: str
|
||||
has_redeemed: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def cdp_send_command(ws: Any, method: str, params: Dict[str, Any], timeout: float = 3.0) -> Optional[Dict[str, Any]]:
|
||||
"""Send a raw CDP command and await response with a unique request ID."""
|
||||
req_id = next(_REQ_COUNTER)
|
||||
msg = {"id": req_id, "method": method, "params": params}
|
||||
ws.send(json.dumps(msg))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
raw = ws.recv()
|
||||
except Exception:
|
||||
break
|
||||
resp = json.loads(raw)
|
||||
if resp.get("id") == req_id:
|
||||
return resp.get("result", {})
|
||||
return None
|
||||
|
||||
|
||||
def cdp_dispatch_mouse_click(ws: Any, x: float, y: float, timeout: float = 3.0) -> bool:
|
||||
"""Simulate real mousePressed and mouseReleased events at (x, y)."""
|
||||
p1 = cdp_send_command(
|
||||
ws,
|
||||
"Input.dispatchMouseEvent",
|
||||
{"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1},
|
||||
timeout=timeout,
|
||||
)
|
||||
p2 = cdp_send_command(
|
||||
ws,
|
||||
"Input.dispatchMouseEvent",
|
||||
{"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1},
|
||||
timeout=timeout,
|
||||
)
|
||||
return p1 is not None and p2 is not None
|
||||
|
||||
|
||||
def cdp_click_element_by_selector(ws: Any, selector: str, timeout: float = 3.0) -> bool:
|
||||
"""Find element by selector, calculate bounding box center, and dispatch real mouse click."""
|
||||
js_rect = f"""(() => {{
|
||||
const el = document.querySelector({json.dumps(selector)});
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) return null;
|
||||
return {{x: r.x + r.width / 2, y: r.y + r.height / 2}};
|
||||
}})()"""
|
||||
rect = cdp_evaluate(ws, js_rect, timeout=timeout)
|
||||
if not rect or "x" not in rect:
|
||||
return False
|
||||
return cdp_dispatch_mouse_click(ws, float(rect["x"]), float(rect["y"]), timeout=timeout)
|
||||
|
||||
|
||||
def cdp_send_escape(ws: Any) -> None:
|
||||
"""Send an Escape keydown event to dismiss popovers and modals."""
|
||||
js_escape = """(() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
keyCode: 27,
|
||||
which: 27,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
})()"""
|
||||
cdp_evaluate(ws, js_escape)
|
||||
|
||||
|
||||
class SettingsRPA:
|
||||
"""Automates Settings menu, dialogs, and usage inspection for a NetVM node."""
|
||||
|
||||
def __init__(self, node: str, timeout: float = 4.0):
|
||||
self.node = node
|
||||
self.timeout = timeout
|
||||
self.ws = None
|
||||
self.page = None
|
||||
|
||||
def connect(self) -> SettingsRPA:
|
||||
if self.ws is None:
|
||||
self.ws, self.page = get_cdp_ws(self.node, timeout=self.timeout)
|
||||
return self
|
||||
|
||||
def close(self) -> None:
|
||||
if self.ws is not None:
|
||||
try:
|
||||
self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
def __enter__(self) -> SettingsRPA:
|
||||
return self.connect()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.close()
|
||||
|
||||
def ensure_main_chat(self) -> bool:
|
||||
"""Ensure agent is in main chat home view so dock and main toolbar are active."""
|
||||
self.connect()
|
||||
js_is_main = """(() => {
|
||||
return Boolean(document.querySelector('[data-testid="hatch-invite-friends-button"]'));
|
||||
})()"""
|
||||
if cdp_evaluate(self.ws, js_is_main):
|
||||
return True
|
||||
|
||||
# Click the chat dock item to return to main
|
||||
cdp_evaluate(
|
||||
self.ws,
|
||||
"""(() => {
|
||||
const chatLink = document.querySelector('[data-testid="hatch-nav-chat"]');
|
||||
if (chatLink) { chatLink.click(); return true; }
|
||||
return false;
|
||||
})()""",
|
||||
)
|
||||
time.sleep(1.0)
|
||||
return bool(cdp_evaluate(self.ws, js_is_main))
|
||||
|
||||
def is_menu_open(self) -> bool:
|
||||
"""Check if dock radix menu is currently open."""
|
||||
self.connect()
|
||||
js_check = """(() => {
|
||||
return document.querySelectorAll('[role="menuitem"]').length > 0;
|
||||
})()"""
|
||||
return bool(cdp_evaluate(self.ws, js_check))
|
||||
|
||||
def is_settings_dialog_open(self) -> bool:
|
||||
"""Check if Settings dialog is currently open."""
|
||||
self.connect()
|
||||
js_check = """(() => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return false;
|
||||
const h = d.querySelector('h2');
|
||||
return h && h.innerText.trim().toLowerCase() === 'settings';
|
||||
})()"""
|
||||
return bool(cdp_evaluate(self.ws, js_check))
|
||||
|
||||
def open_dock_menu(self) -> bool:
|
||||
"""Open the dock settings menu via real mouse clicks on hatch-dock-more."""
|
||||
self.connect()
|
||||
if self.is_menu_open():
|
||||
return True
|
||||
|
||||
# Dispatch real mouse click on dock button
|
||||
clicked = cdp_click_element_by_selector(self.ws, '[data-testid="hatch-dock-more"]', timeout=self.timeout)
|
||||
if not clicked:
|
||||
return False
|
||||
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline:
|
||||
if self.is_menu_open():
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
def close_dock_menu(self) -> None:
|
||||
"""Dismiss dock menu."""
|
||||
if self.is_menu_open():
|
||||
cdp_send_escape(self.ws)
|
||||
time.sleep(0.2)
|
||||
|
||||
def open_settings_dialog(self) -> bool:
|
||||
"""Open the Settings dialog through the dock settings menu."""
|
||||
self.connect()
|
||||
if self.is_settings_dialog_open():
|
||||
return True
|
||||
|
||||
# Ensure menu is open
|
||||
if not self.open_dock_menu():
|
||||
return False
|
||||
|
||||
time.sleep(0.3)
|
||||
# Click "Settings" menu item
|
||||
js_click_item = """(() => {
|
||||
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
|
||||
const target = items.find(el => el.getAttribute('data-pel-click') === 'settings_nav_click') ||
|
||||
items.find(el => (el.innerText || '').trim() === 'Settings');
|
||||
if (target) {
|
||||
target.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()"""
|
||||
clicked = cdp_evaluate(self.ws, js_click_item)
|
||||
if not clicked:
|
||||
return False
|
||||
|
||||
deadline = time.time() + 3.0
|
||||
while time.time() < deadline:
|
||||
if self.is_settings_dialog_open():
|
||||
return True
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
def close_settings_dialog(self) -> None:
|
||||
"""Dismiss Settings dialog with Escape or Close button."""
|
||||
if self.is_settings_dialog_open():
|
||||
cdp_send_escape(self.ws)
|
||||
time.sleep(0.3)
|
||||
# If still open, try close button
|
||||
if self.is_settings_dialog_open():
|
||||
cdp_evaluate(
|
||||
self.ws,
|
||||
"""(() => {
|
||||
const btn = Array.from(document.querySelectorAll('[role="dialog"] button')).find(b => (b.innerText||'').trim() === 'Close');
|
||||
if (btn) btn.click();
|
||||
})()""",
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
def select_tab(self, tab_name: str) -> bool:
|
||||
"""Select a section tab inside the Settings dialog."""
|
||||
self.connect()
|
||||
if not self.open_settings_dialog():
|
||||
return False
|
||||
|
||||
js_tab = f"""(() => {{
|
||||
const btns = Array.from(document.querySelectorAll('[role="dialog"] button'));
|
||||
const tab = btns.find(b => (b.innerText||'').trim().toLowerCase() === {json.dumps(tab_name.lower())});
|
||||
if (tab) {{
|
||||
tab.click();
|
||||
return true;
|
||||
}}
|
||||
return false;
|
||||
}})()"""
|
||||
res = cdp_evaluate(self.ws, js_tab)
|
||||
if res:
|
||||
time.sleep(0.4)
|
||||
return True
|
||||
return False
|
||||
|
||||
def read_usage(self, keep_dialog_open: bool = False) -> NodeUsage:
|
||||
"""Open settings dialog, read token and weekly limit usage, and optionally close dialog."""
|
||||
self.connect()
|
||||
opened = self.open_settings_dialog()
|
||||
if not opened:
|
||||
raise RuntimeError(f"Could not open Settings dialog for node {self.node}")
|
||||
|
||||
# Ensure General tab is active
|
||||
self.select_tab("General")
|
||||
time.sleep(0.4)
|
||||
|
||||
js_usage = """(() => {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
if (!dialog) return null;
|
||||
const fullText = dialog.innerText || '';
|
||||
const bars = Array.from(dialog.querySelectorAll('[role="progressbar"]')).map(b => ({
|
||||
label: b.getAttribute('aria-label') || '',
|
||||
percent: parseInt(b.getAttribute('aria-valuenow') || '0', 10),
|
||||
raw_text: b.parentElement ? b.parentElement.innerText.trim() : ''
|
||||
}));
|
||||
|
||||
// Extract plan
|
||||
let plan = 'Free plan';
|
||||
if (fullText.includes('Free plan')) plan = 'Free plan';
|
||||
else if (fullText.includes('Pro plan')) plan = 'Pro plan';
|
||||
|
||||
// Extract reset text
|
||||
let resetText = '';
|
||||
const mReset = fullText.match(/Weekly limit resets on [^\\n]+/);
|
||||
if (mReset) resetText = mReset[0];
|
||||
|
||||
// Extract tokens left
|
||||
let tokensLeft = '';
|
||||
const mLeft = fullText.match(/\\(([^\\)]+tokens left)\\)/);
|
||||
if (mLeft) tokensLeft = mLeft[1];
|
||||
else if (fullText.includes('0 tokens left')) tokensLeft = '0 tokens left';
|
||||
|
||||
return {
|
||||
dialogText: fullText,
|
||||
bars: bars,
|
||||
plan: plan,
|
||||
resetText: resetText,
|
||||
tokensLeft: tokensLeft
|
||||
};
|
||||
})()"""
|
||||
# Poll up to 4.0s for usage elements to render in React
|
||||
deadline = time.time() + 4.0
|
||||
raw = None
|
||||
while time.time() < deadline:
|
||||
res = cdp_evaluate(self.ws, js_usage)
|
||||
if res and (res.get("bars") or "Weekly limit" in res.get("dialogText", "") or "Additional tokens" in res.get("dialogText", "")):
|
||||
raw = res
|
||||
break
|
||||
time.sleep(0.3)
|
||||
if not raw:
|
||||
raw = cdp_evaluate(self.ws, js_usage)
|
||||
|
||||
if not keep_dialog_open:
|
||||
self.close_settings_dialog()
|
||||
|
||||
if not raw:
|
||||
raise RuntimeError(f"Failed to read usage data from Settings dialog on {self.node}")
|
||||
|
||||
bars = raw.get("bars", [])
|
||||
weekly_pct = 0
|
||||
extra_pct = 0
|
||||
extra_status = "Never expires"
|
||||
|
||||
for b in bars:
|
||||
label = b.get("label", "").lower()
|
||||
detail = b.get("raw_text", "").lower()
|
||||
if "free plan" in label or "weekly" in detail:
|
||||
weekly_pct = b.get("percent", 0)
|
||||
elif "additional tokens" in label or "additional" in detail:
|
||||
extra_pct = b.get("percent", 0)
|
||||
|
||||
# Blocked condition: weekly limit 100% and additional tokens 100% or 0 tokens left
|
||||
tokens_left = raw.get("tokensLeft", "")
|
||||
is_blocked = (weekly_pct >= 100 and extra_pct >= 100) or ("0 tokens left" in tokens_left)
|
||||
|
||||
# has_redeemed binary: If the "Additional tokens" ticker is present in Settings (or "Redeem invite code" entry is absent),
|
||||
# the agent has already redeemed an invite code.
|
||||
has_extra_ticker = any("additional tokens" in b.get("label", "").lower() or "additional" in b.get("raw_text", "").lower() for b in bars)
|
||||
has_redeemed = has_extra_ticker or ("Additional tokens" in raw.get("dialogText", ""))
|
||||
|
||||
return NodeUsage(
|
||||
node=self.node,
|
||||
plan=raw.get("plan", "Free plan"),
|
||||
weekly_reset_text=raw.get("resetText", ""),
|
||||
weekly_percent_used=weekly_pct,
|
||||
extra_tokens_status=extra_status,
|
||||
extra_percent_used=extra_pct,
|
||||
extra_tokens_remaining=tokens_left or ("0 tokens left" if extra_pct >= 100 else "Unknown"),
|
||||
is_blocked=is_blocked,
|
||||
bars=bars,
|
||||
dialog_text=raw.get("dialogText", ""),
|
||||
has_redeemed=has_redeemed,
|
||||
)
|
||||
|
||||
def check_redeem_entrypoint(self) -> Dict[str, Any]:
|
||||
"""Check if 'Redeem invite code' option is available in Settings -> General."""
|
||||
self.connect()
|
||||
if not self.open_settings_dialog():
|
||||
return {"available": False, "reason": "could_not_open_settings"}
|
||||
|
||||
self.select_tab("General")
|
||||
time.sleep(0.3)
|
||||
|
||||
js_check = """(() => {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
if (!dialog) return {available: false, reason: 'no_dialog'};
|
||||
const items = Array.from(dialog.querySelectorAll('button, div, span'));
|
||||
const redeemItem = items.find(el => (el.innerText || '').trim() === 'Redeem invite code');
|
||||
return {
|
||||
available: Boolean(redeemItem),
|
||||
has_text: dialog.innerText.includes('Redeem invite code')
|
||||
};
|
||||
})()"""
|
||||
res = cdp_evaluate(self.ws, js_check)
|
||||
self.close_settings_dialog()
|
||||
return res or {"available": False, "reason": "evaluation_failed"}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Settings menu RPA for NetVM agents")
|
||||
parser.add_argument("node", help="Node name (e.g. 646, pip, muse, opm)")
|
||||
parser.add_argument("--usage", action="store_true", help="Read token and weekly quota usage")
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON output")
|
||||
parser.add_argument("--check-redeem", action="store_true", help="Check if Redeem Invite Code is available")
|
||||
args = parser.parse_args()
|
||||
|
||||
rpa = SettingsRPA(args.node)
|
||||
try:
|
||||
rpa.connect()
|
||||
if args.usage:
|
||||
usage = rpa.read_usage()
|
||||
if args.json:
|
||||
print(json.dumps(usage.to_dict(), indent=2))
|
||||
else:
|
||||
status_str = "BLOCKED (LIMIT REACHED)" if usage.is_blocked else "ACTIVE"
|
||||
print(f"=== Node {usage.node} Usage ===")
|
||||
print(f" Plan: {usage.plan}")
|
||||
print(f" Weekly Reset: {usage.weekly_reset_text} ({usage.weekly_percent_used}% used)")
|
||||
print(f" Extra Tokens: {usage.extra_tokens_remaining} ({usage.extra_percent_used}% used)")
|
||||
print(f" Status: {status_str}")
|
||||
elif args.check_redeem:
|
||||
info = rpa.check_redeem_entrypoint()
|
||||
print(json.dumps(info, indent=2))
|
||||
else:
|
||||
usage = rpa.read_usage()
|
||||
print(json.dumps(usage.to_dict(), indent=2))
|
||||
finally:
|
||||
rpa.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user