diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b8ab04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# NetVM Transfers & Local State +transfers/ +*.log +__pycache__/ +*.pyc +*.bak +*.bak-* +*.orig diff --git a/bin/dm.py b/bin/dm.py index 66f5a64..88b2a5c 100755 --- a/bin/dm.py +++ b/bin/dm.py @@ -35,13 +35,198 @@ import sys import uuid import json import os -from datetime import datetime, timezone +import urllib.request +import urllib.parse +from datetime import datetime, timezone, timedelta API = "/home/super/Projects/NetVM/bin/muse-chat-api.py" NETVM_EXEC = "/home/super/Projects/NetVM/bin/netvm-exec.sh" VALID_AGENTS = ["muse", "pip", "646", "opm"] +VALID_SENDERS = ["muse", "pip", "646", "opm", "super"] +VALID_RECIPIENTS = ["muse", "pip", "646", "opm"] + +# ---- Canonical follow-up tags (2026-10-04, DEPLOY-DECISIONS.md) ---- +# Tags declare follow-up policy at send time. They are metadata only: +# stripped from the delivered text, recorded on send_start/sent log +# events. The request-store sweeper (VM side) guarantees nudges, +# retries, closure, and escalation. Untagged DMs behave exactly as before. +TAG_TIMEOUT_MIN = 60 +TAG_TIMEOUT_MAX = 604800 +TAG_TIMEOUT_DEFAULT = 3600 # 1h, per deploy decisions +TAG_NUDGES_MIN = 0 +TAG_NUDGES_MAX = 10 +TAG_NUDGES_DEFAULT = 2 +TAG_ROUTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") +TAG_THREAD_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") +# Trailing-token form: [reply:expected] [reply:timeout=7200] ... +TAG_TOKEN_RE = re.compile(r"\[([A-Za-z][A-Za-z0-9:]*)" + r"(?:=([^\]]*))?\]\s*$") + + +def parse_canonical_tag(item): + """Parse one canonical tag token. Returns (key, value). + Bare 'reply:expected' -> ('reply:expected', True). + 'route:X' / 'thread:X' use colon form; 'reply:timeout=N' etc use '='. + Raises ValueError with a human message on any problem.""" + item = item.strip() + if "=" in item: + k, v = item.split("=", 1) + k, v = k.strip(), v.strip() + elif item.startswith("route:"): + k, v = "route", item[len("route:"):].strip() + elif item.startswith("thread:"): + k, v = "thread", item[len("thread:"):].strip() + else: + k, v = item, True + if k == "reply:expected": + # Bare 'reply:expected' or 'reply:expected=' (empty value from + # --tag flag generation); anything else is an error. + if v is not True and v != "": + raise ValueError("reply:expected takes no value") + return k, True + if k == "reply:timeout": + try: + n = int(v) + except (TypeError, ValueError): + raise ValueError("reply:timeout must be integer seconds") + if not TAG_TIMEOUT_MIN <= n <= TAG_TIMEOUT_MAX: + raise ValueError("reply:timeout must be %d..%d" + % (TAG_TIMEOUT_MIN, TAG_TIMEOUT_MAX)) + return k, n + if k == "reply:nudges": + try: + n = int(v) + except (TypeError, ValueError): + raise ValueError("reply:nudges must be an integer") + if not TAG_NUDGES_MIN <= n <= TAG_NUDGES_MAX: + raise ValueError("reply:nudges must be %d..%d" + % (TAG_NUDGES_MIN, TAG_NUDGES_MAX)) + return k, n + if k == "reply:escalate": + if not v or not TAG_ROUTE_RE.fullmatch(str(v)): + raise ValueError("reply:escalate must be an identity") + return k, str(v) + if k == "route": + if not TAG_ROUTE_RE.fullmatch(str(v)): + raise ValueError("route must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") + return k, str(v) + if k == "thread": + if not TAG_THREAD_RE.fullmatch(str(v)): + raise ValueError("thread must match ^[A-Za-z0-9_-]{1,64}$") + return k, str(v) + raise ValueError("unknown tag %r; known: reply:expected, reply:timeout=, " + "reply:nudges=, reply:escalate=, route:, thread:" % (k,)) + + +def parse_tags(tag_list): + """Validate --tag items. Returns canonical dict. Raises ValueError.""" + tags = {} + for item in tag_list or []: + k, v = parse_canonical_tag(item) + if k in tags: + raise ValueError("duplicate tag %r" % (k,)) + tags[k] = v + # Cross-field consistency: policy tags require reply:expected. + if "reply:expected" not in tags: + for k in ("reply:timeout", "reply:nudges", "reply:escalate"): + if k in tags: + raise ValueError("tag %s requires reply:expected" % (k,)) + # Defaults when reply:expected without explicit policy. + if "reply:expected" in tags: + tags.setdefault("reply:timeout", TAG_TIMEOUT_DEFAULT) + tags.setdefault("reply:nudges", TAG_NUDGES_DEFAULT) + return tags + + +def extract_trailing_tags(message): + """Pull trailing [tag] tokens off message text. Returns + (stripped_message, tags_dict). Unknown brackets are left alone.""" + tags = {} + text = message.rstrip() + while True: + m = TAG_TOKEN_RE.search(text) + if not m: + break + key = m.group(1) + # Only consume known canonical keys; anything else stays. + probe = key + ("=" + m.group(2) if m.group(2) is not None else "") + try: + k, v = parse_canonical_tag(probe) + except ValueError: + break + if k in tags: + break # duplicate; leave it in the text + tags[k] = v + text = text[:m.start()].rstrip() + return text, tags + + +def merge_tags(flag_tags, cli_tags, text_tags): + """Precedence: --tag > dedicated flags > trailing text tokens.""" + merged = dict(text_tags) + merged.update(flag_tags) + merged.update(cli_tags) + # Defaults when reply:expected without explicit policy. + if "reply:expected" in merged: + merged.setdefault("reply:timeout", TAG_TIMEOUT_DEFAULT) + merged.setdefault("reply:nudges", TAG_NUDGES_DEFAULT) + return merged + + +def tags_from_flags(a): + """Build canonical tags from argparse --expect-reply et al.""" + tags = {} + if getattr(a, "expect_reply", False): + tags["reply:expected"] = True + if getattr(a, "reply_timeout", None) is not None: + k, v = parse_canonical_tag("reply:timeout=%s" % a.reply_timeout) + tags[k] = v + if getattr(a, "reply_nudges", None) is not None: + k, v = parse_canonical_tag("reply:nudges=%s" % a.reply_nudges) + tags[k] = v + if getattr(a, "reply_escalate", None): + k, v = parse_canonical_tag("reply:escalate=%s" % a.reply_escalate) + tags[k] = v + if getattr(a, "route", None): + k, v = parse_canonical_tag("route:%s" % a.route) + tags[k] = v + if getattr(a, "thread", None): + k, v = parse_canonical_tag("thread:%s" % a.thread) + tags[k] = v + return tags + LOG_FILE = "/home/super/Projects/NetVM/dm-log.jsonl" +# Well-known sidechat name -> thread UUID aliases. +# These bypass fuzzy name matching for reliable placement. +# Resolved 2026-10-04. Add new entries as sidechats are created. +SIDCHAT_ALIASES = { + "646-opm-work": "d410b9ad-f667-465f-a103-43fabc0f69fe", +} + +def resolve_sidechat_target(target): + """Resolve target alias or name to UUID dynamically from job-sidechats.json.""" + if not target or target == "main": + return target + if re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", target.lower()): + return target.lower() + if target in SIDCHAT_ALIASES: + return SIDCHAT_ALIASES[target] + sc_file = "/home/super/Projects/NetVM/job-sidechats.json" + if os.path.exists(sc_file): + try: + with open(sc_file, "r", encoding="utf-8") as f: + sc_data = json.load(f) + val = sc_data.get(target) + if isinstance(val, dict): + return val.get("thread_uuid") or val.get("uuid") or target + elif isinstance(val, str): + return val + except Exception: + pass + return target + + def log_event(event): """Append to JSONL log.""" event["ts"] = datetime.now(timezone.utc).isoformat() @@ -63,7 +248,8 @@ def run_full(cmd, timeout=60): result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) return result.returncode, result.stdout.strip(), result.stderr.strip() -def dm_send(agent, target, message, verify=True, raw=False, to_agent=None): +def dm_send(agent, target, message, verify=True, raw=False, + to_agent=None, tags=None, nudge_meta=None): """Send a DM. raw=True sends verbatim (for pre-signed messages from dm-sign.sh, which already carry [from:X] [id:Y]): no tagging, no truncation. Non-raw messages are tagged [from:] [id:].""" @@ -71,10 +257,10 @@ def dm_send(agent, target, message, verify=True, raw=False, to_agent=None): # agent is the sender (for attribution). If to_agent is None, send to own chat. recipient = to_agent if to_agent else agent - if agent not in VALID_AGENTS: + if agent not in VALID_SENDERS: print(f"ERROR: Unknown agent {agent}", file=sys.stderr) sys.exit(1) - if recipient not in VALID_AGENTS: + if recipient not in VALID_RECIPIENTS: print(f"ERROR: Unknown recipient {recipient}", file=sys.stderr) sys.exit(1) @@ -87,15 +273,85 @@ def dm_send(agent, target, message, verify=True, raw=False, to_agent=None): # Single unified attribution format (matches verify-sig's regex). tagged = f"[from:{agent}] [id:{msg_id}] {message}" - log_event({"type": "send_start", "id": msg_id, "agent": agent, "to": recipient, "target": target, "msg": message[:100]}) + # Canonical follow-up tags: trailing [bracket] tokens are metadata, + # stripped from the delivered text, recorded on the log events. + tags = tags or {} + if not raw: + message, text_tags = extract_trailing_tags(message) + if text_tags: + # Re-tag with the stripped body so the wire text is clean. + tagged = f"[from:{agent}] [id:{msg_id}] {message}" + for k, v in text_tags.items(): + tags.setdefault(k, v) + _log = {"type": "send_start", "id": msg_id, "agent": agent, "to": recipient, "target": target, "msg": message[:100], "tags": tags} + if nudge_meta: + _log["nudge_meta"] = nudge_meta + log_event(_log) # Navigate the RECIPIENT's browser to the target chat (the send and the # read-back both happen there; navigating the sender's browser was a bug # for cross-operator side-chat targets). + # Use run_full: a silent navigation failure used to send the message to + # whatever chat the browser happened to be parked on (2026-10-04). + thread_uuid = None + thread_url = None + is_new_sidechat = False + # Resolve well-known aliases or dynamic thread mappings to UUIDs. + nav_target = resolve_sidechat_target(target) + if nav_target != target: + log_event({"type": "alias_resolved", "id": msg_id, "target": target, "thread_uuid": nav_target}) if target == "main": - run(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main") + _rc, _out, _err = run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main") else: - run(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat use {target}") + _rc, _out, _err = run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat use {nav_target}") + if _rc != 0: + log_event({"type": "nav_failed", "id": msg_id, "agent": agent, "to": recipient, + "target": target, "rc": _rc, "err": _err[:200]}) + print(f"DM {msg_id} from {agent} to {recipient}/{target}: FAILED (navigation rc={_rc}: {_err[:120]})", + file=sys.stderr) + sys.exit(1) + # cmd_sidechat_use exits 0 even on NOTFOUND (it prints "Navigated to: NOTFOUND"). + # If sidechat is not found, auto-provision a new thread via `sidechat create`! + if target != "main": + if "NOTFOUND" in _out: + log_event({"type": "sidechat_autoprovision_start", "id": msg_id, "agent": agent, + "to": recipient, "target": target}) + _c_rc, _c_out, _c_err = run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat create") + if _c_rc != 0 or "Created:" not in _c_out: + log_event({"type": "nav_failed", "id": msg_id, "agent": agent, "to": recipient, + "target": target, "reason": "autoprovision_create_failed", + "rc": _c_rc, "err": _c_err[:200], "out": _c_out[:200]}) + print(f"DM {msg_id} from {agent} to {recipient}/{target}: FAILED (sidechat auto-creation failed: {_c_err[:120]})", + file=sys.stderr) + sys.exit(1) + is_new_sidechat = True + log_event({"type": "nav_ok", "id": msg_id, "agent": agent, "to": recipient, + "target": target, "status": "sidechat_created_pending_uuid"}) + else: + m = re.search(r"/thread/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", _out, re.I) + if m: + thread_uuid = m.group(1).lower() + thread_url = "https://muse.ai/thread/" + thread_uuid + else: + # Navigation "succeeded" but we can't confirm where we landed. + # Don't trust it: fail instead of sending to an unknown chat. + log_event({"type": "nav_failed", "id": msg_id, "agent": agent, "to": recipient, + "target": target, "reason": "no_thread_url", "out": _out[:200]}) + print(f"DM {msg_id} from {agent} to {recipient}/{target}: FAILED (could not confirm thread URL)", + file=sys.stderr) + sys.exit(1) + # If we resolved via alias, assert the browser actually landed on + # the expected thread. A mismatch means the navigation didn't take + # (stale URL, blocked nav) -- fail instead of misdelivering. + if nav_target != target and thread_uuid != nav_target.lower(): + log_event({"type": "nav_failed", "id": msg_id, "agent": agent, "to": recipient, + "target": target, "reason": "uuid_mismatch", + "expected": nav_target.lower(), "got": thread_uuid}) + print(f"DM {msg_id} from {agent} to {recipient}/{target}: FAILED (navigation landed on wrong thread)", + file=sys.stderr) + sys.exit(1) + log_event({"type": "nav_ok", "id": msg_id, "agent": agent, "to": recipient, + "target": target, "thread_uuid": thread_uuid}) time.sleep(2) @@ -110,16 +366,43 @@ def dm_send(agent, target, message, verify=True, raw=False, to_agent=None): max_retries = 3 delivered = False for attempt in range(max_retries): - run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} send "{safe}"', - priority="high") + send_out = run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} send "{safe}"', + priority="high") time.sleep(3) # Wait for message to propagate - # Verify by reading recipient's chat (independent check, not local echo) + # The send command reports 'sent' / 'enter-sent' / 'NOINPUT'. + # 'NOINPUT' (or empty output) means the compose box was never found: + # retrying verification would be meaningless, so fail fast. + if send_out.strip() not in ("sent", "enter-sent"): + log_event({"type": "send_failed", "id": msg_id, "agent": agent, "to": recipient, + "target": target, "attempt": attempt + 1, "send_out": send_out[:80]}) + if attempt < max_retries - 1: + time.sleep(2) + continue + + # Assert the composer cleared: if our text (with the [id:...] tag) is + # still sitting in the compose box, the send click never fired and any + # later "verification" would be a false positive (2026-10-04 bug). + compose = run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} compose_check') + if compose and compose != "NOCOMPOSE" and msg_id in compose: + log_event({"type": "compose_stuck", "id": msg_id, "agent": agent, "to": recipient, + "target": target, "attempt": attempt + 1}) + # Clear the stuck draft so a retry starts clean. + run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} send ""', + priority="high") + if attempt < max_retries - 1: + time.sleep(2) + continue + + # Verify by reading recipient's chat (independent check, not local echo). + # cmd_messages now excludes the composer subtree, so a hit here means + # the message is actually in the chat history. try: check_msgs = run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} messages 5 200') if msg_id in check_msgs: delivered = True - log_event({"type": "verified", "id": msg_id, "agent": agent, "to": recipient, "target": target, "attempt": attempt + 1}) + log_event({"type": "verified", "id": msg_id, "agent": agent, "to": recipient, "target": target, + "thread_uuid": thread_uuid, "attempt": attempt + 1}) break else: log_event({"type": "retry", "id": msg_id, "agent": agent, "to": recipient, "attempt": attempt + 1}) @@ -129,14 +412,61 @@ def dm_send(agent, target, message, verify=True, raw=False, to_agent=None): if attempt < max_retries - 1: time.sleep(2) # Brief pause before retry + if delivered and is_new_sidechat: + # We sent to /thread/new; muse.ai now assigns a permanent UUID. + # Capture the current URL BEFORE navigating away to main. + for _ in range(10): + _u_rc, _u_out, _u_err = run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} url") + m_uuid = re.search(r"/thread/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", _u_out, re.I) + if m_uuid: + thread_uuid = m_uuid.group(1).lower() + thread_url = "https://muse.ai/thread/" + thread_uuid + break + time.sleep(1) + + if thread_uuid: + sc_file = "/home/super/Projects/NetVM/job-sidechats.json" + try: + sc_data = {} + if os.path.exists(sc_file): + with open(sc_file, "r", encoding="utf-8") as f: + sc_data = json.load(f) + sc_data[target] = { + "thread_uuid": thread_uuid, + "agent": recipient, + "created_at": datetime.now(timezone.utc).isoformat() + } + tmp_sc = f"{sc_file}.tmp.{os.getpid()}" + with open(tmp_sc, "w", encoding="utf-8") as f: + json.dump(sc_data, f, indent=2) + os.replace(tmp_sc, sc_file) + log_event({"type": "sidechat_autoprovisioned", "id": msg_id, "target": target, + "thread_uuid": thread_uuid, "agent": recipient}) + except Exception as e: + log_event({"type": "sidechat_persist_error", "id": msg_id, "error": str(e)[:100]}) + tags["thread"] = thread_uuid + else: + log_event({"type": "sidechat_uuid_capture_failed", "id": msg_id, "out": _u_out[:200]}) + + if delivered and thread_uuid and "thread" not in tags: + tags["thread"] = thread_uuid + # Park the recipient's browser back on main run(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main") log_event({"type": "send_done", "id": msg_id, "agent": agent, "to": recipient, "target": target}) if delivered: - log_event({"type": "sent", "id": msg_id, "agent": agent, "to": recipient, "target": target, "verified": True}) + _sent = {"type": "sent", "id": msg_id, "agent": agent, "to": recipient, "target": target, "verified": True, "tags": tags} + if nudge_meta: + _sent["nudge_meta"] = nudge_meta + log_event(_sent) print(f"DM {msg_id} from {agent} to {recipient}/{target}: SENT and VERIFIED") + # Follow-up record creation: if tags request tracking, POST to the + # VM's /api/box/followups endpoint. Fail gracefully -- the DM already + # sent, so a record-creation failure is logged, not fatal. + if tags.get("reply:expected"): + _register_followup(msg_id, agent, recipient, target, tags) else: log_event({"type": "failed", "id": msg_id, "agent": agent, "to": recipient, "target": target, "verified": False}) print(f"DM {msg_id} from {agent} to {recipient}/{target}: FAILED (not found in recipient chat after {max_retries} attempts)", file=sys.stderr) @@ -150,10 +480,11 @@ def dm_read(agent, target, n=5, quiet=False, width=200): print(f"ERROR: Unknown agent {agent}", file=sys.stderr) sys.exit(1) + nav_target = resolve_sidechat_target(target) if target == "main": run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat main") else: - run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat use {target}") + run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat use {nav_target}") time.sleep(2) rc, msgs, err = run_full(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} messages {n} {width}") @@ -187,11 +518,12 @@ def dm_select(agent, target=None): return r.stdout.strip() if target: + nav_target = resolve_sidechat_target(target) # Switch to target conversation if target == "main": run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat main") else: - run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat use {target}") + run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat use {nav_target}") # Store selection with open(state_file, "w") as f: json.dump({"agent": agent, "target": target}, f) @@ -310,15 +642,50 @@ def main(): sub = p.add_subparsers(dest='cmd', required=True) ps = sub.add_parser('send', help='Send a DM (tagged; delivery confirmed by recipient read-back, up to 3 attempts)') - ps.add_argument('--agent', required=True, choices=VALID_AGENTS) - ps.add_argument('--to', required=False, choices=VALID_AGENTS, default=None, + ps.add_argument('--agent', required=True, choices=VALID_SENDERS) + ps.add_argument('--to', required=False, choices=VALID_RECIPIENTS, default=None, help='Recipient operator (for cross-operator DMs). Uses recipient\'s browser/chat.') ps.add_argument('--target', required=True) ps.add_argument('--no-verify', action='store_true', help='Accepted for compatibility but ignored: recipient-side read-back verification always runs.') ps.add_argument('--raw', action='store_true', help='Send verbatim: no tagging, no truncation (for pre-signed messages from dm-sign.sh)') + ps.add_argument('--expect-reply', action='store_true', + help='Tag reply:expected: create a follow-up record (nudge/escalate per policy)') + ps.add_argument('--reply-timeout', default=None, + help='reply:timeout= seconds until nudge/escalation (60..604800, default 3600)') + ps.add_argument('--reply-nudges', default=None, + help='reply:nudges= max nudges before escalation (0..10, default 2)') + ps.add_argument('--reply-escalate', default=None, + help='reply:escalate= identity to alert on timeout') + ps.add_argument('--route', default=None, help='route: attach follow-up to a conversation route') + ps.add_argument('--thread', default=None, help='thread: associate DM with a thread UUID') + ps.add_argument('--tag', action='append', default=[], + help='Raw canonical tag (repeatable): reply:expected, reply:timeout=N, reply:nudges=N, reply:escalate=X, route:R, thread:U') + ps.add_argument('--nudge-meta', default=None, + help='JSON string of nudge metadata (followup_id, nudge_n, dm_id) for audit logging') ps.add_argument('message') - ps.set_defaults(func=lambda a: dm_send(a.agent, a.target, a.message, verify=not a.no_verify, raw=a.raw, to_agent=a.to)) + def _send_with_tags(a): + try: + cli_tags = parse_tags(a.tag) + flag_tags = tags_from_flags(a) + tags = merge_tags(flag_tags, cli_tags, {}) + except ValueError as e: + print(f"TAG_ERROR: {e}", file=sys.stderr) + sys.exit(2) + nudge_meta = None + if a.nudge_meta: + try: + import json as _json + nudge_meta = _json.loads(a.nudge_meta) + if not isinstance(nudge_meta, dict): + raise ValueError("nudge-meta must be a JSON object") + except Exception as e: + print(f"NUDGE_META_ERROR: {e}", file=sys.stderr) + sys.exit(2) + return dm_send(a.agent, a.target, a.message, verify=not a.no_verify, + raw=a.raw, to_agent=a.to, tags=tags, + nudge_meta=nudge_meta) + ps.set_defaults(func=_send_with_tags) psel = sub.add_parser('select', help='Select active conversation') psel.add_argument('--agent', required=True, choices=VALID_AGENTS) @@ -343,8 +710,8 @@ def main(): pl.set_defaults(func=lambda a: dm_log(a.n)) pt = sub.add_parser('thread', help='Thread from one agent to another') - pt.add_argument('--from', dest='from_agent', required=True, choices=VALID_AGENTS) - pt.add_argument('--to', dest='to_agent', required=True, choices=VALID_AGENTS) + pt.add_argument('--from', dest='from_agent', required=True, choices=VALID_SENDERS) + pt.add_argument('--to', dest='to_agent', required=True, choices=VALID_RECIPIENTS) pt.add_argument('--target', required=True) pt.add_argument('message') pt.set_defaults(func=lambda a: dm_thread(a.from_agent, a.to_agent, a.target, a.message)) @@ -352,5 +719,114 @@ def main(): args = p.parse_args() args.func(args) +BOX_API_BASE = os.environ.get("BOX_API_BASE", "https://box.muse-dev.online") +BOX_SIGN_KEY = os.environ.get("BOX_SIGN_KEY", os.path.expanduser("~/.ssh/id_ed25519")) + + +def _box_sign(identity, endpoint): + """Sign a box API request. Returns (ts, sig_armored) or (None, None).""" + ts = str(int(time.time())) + payload = f"{ts}\n{endpoint}".encode() + try: + p = subprocess.run( + ["ssh-keygen", "-Y", "sign", "-f", BOX_SIGN_KEY, "-n", "box"], + input=payload, capture_output=True, timeout=15) + if p.returncode != 0: + return None, None + return ts, p.stdout.decode() + except Exception: + return None, None + + +def _register_followup(msg_id, agent, recipient, target, tags): + """POST a follow-up record to the VM. Never raises -- logs and returns.""" + identity_map = { + "bl": "bl", + "opm": "operator-main", + "646": "operator-646", + } + identity = "bl" + + ts, sig = _box_sign(identity, "followups") + if not ts or not sig: + log_event({"type": "followup_register_failed", "id": msg_id, + "error": "signing failed"}) + return + + tags_obj = {} + if tags.get("reply:expected"): + tags_obj["reply:expected"] = True + for key in ("reply:timeout", "reply:nudges", "reply:escalate", + "route", "thread"): + if key in tags: + tags_obj[key] = tags[key] + + body = { + "dm_id": msg_id, + "to": recipient, + "from": agent, + "target": target, + "tags": tags_obj, + "dm_sent_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + + query = urllib.parse.urlencode({ + "identity": identity, + "ts": ts, + "sig": sig, + }) + url = f"{BOX_API_BASE}/api/box/followups?{query}" + try: + req = urllib.request.Request( + url, data=json.dumps(body).encode(), + headers={"Content-Type": "application/json", + # Cloudflare 1010-blocks python-urllib's default UA; + # use an identifiable custom UA instead. + "User-Agent": "NetVM-dm.py/1.0 (bl)"}, + method="POST") + with urllib.request.urlopen(req, timeout=30) as r: + resp = json.load(r) + log_event({"type": "followup_registered", "id": msg_id, + "request_id": resp.get("request_id"), + "tracked": resp.get("tracked")}) + except Exception as e: + log_event({"type": "followup_register_failed", "id": msg_id, + "error": str(e)[:200]}) + + # Also persist to local followups.json for bl autonomy + try: + f_path = "/home/super/Projects/NetVM/followups.json" + followups = {} + if os.path.exists(f_path): + with open(f_path, "r", encoding="utf-8") as f: + followups = json.load(f) + now_dt = datetime.now(timezone.utc) + timeout_s = int(tags.get("reply:timeout", 3600)) + nudges_n = int(tags.get("reply:nudges", 2)) + deadline_dt = now_dt + timedelta(seconds=timeout_s) + followups[msg_id] = { + "dm_id": msg_id, + "sender": agent, + "recipient": recipient, + "target": target, + "thread_uuid": tags.get("thread"), + "route": tags.get("route"), + "sent_at": now_dt.isoformat(), + "deadline": deadline_dt.isoformat(), + "timeout_s": timeout_s, + "nudges_allowed": nudges_n, + "nudges_sent": 0, + "escalate_to": tags.get("reply:escalate", "opm"), + "status": "pending" + } + tmp = f"{f_path}.tmp.{os.getpid()}" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(followups, f, indent=2) + os.replace(tmp, f_path) + except Exception as e: + log_event({"type": "local_followup_save_failed", "id": msg_id, "error": str(e)[:200]}) + if __name__ == '__main__': main() + + diff --git a/bin/followup-sweeper.py b/bin/followup-sweeper.py new file mode 100755 index 0000000..5048766 --- /dev/null +++ b/bin/followup-sweeper.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +followup-sweeper.py — Autonomous follow-up deadline tracking and nudge sweeper. + +Monitors pending follow-ups in followups.json, delivers progressive nudges to +recipients when deadlines expire (in-thread first, Main Chat on final nudge), +and executes terminal escalations to opm when all nudges are exhausted. + +Usage: + python3 followup-sweeper.py --once + python3 followup-sweeper.py --loop --interval 30 +""" + +import argparse +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timezone, timedelta +from pathlib import Path + +# Paths +NETVM_ROOT = Path("/home/super/Projects/NetVM") +BIN_DIR = NETVM_ROOT / "bin" +FOLLOWUPS_FILE = NETVM_ROOT / "followups.json" +JOB_LOG = NETVM_ROOT / "job-log.jsonl" +DM_PY = BIN_DIR / "dm.py" + + +def utcnow_dt(): + return datetime.now(timezone.utc) + + +def utcnow_str(): + return utcnow_dt().isoformat() + + +def parse_iso(ts_str): + if not ts_str: + return None + try: + ts_clean = ts_str.replace("Z", "+00:00") + dt = datetime.fromisoformat(ts_clean) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except Exception: + return None + + +def load_followups(): + if not FOLLOWUPS_FILE.exists(): + return {} + try: + with open(FOLLOWUPS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def save_followups(data): + tmp_path = f"{FOLLOWUPS_FILE}.tmp.{os.getpid()}" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp_path, FOLLOWUPS_FILE) + + +def append_job_log(entry): + os.makedirs(os.path.dirname(os.path.abspath(JOB_LOG)), exist_ok=True) + with open(JOB_LOG, "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +def send_dm(sender, recipient, target, text): + """Dispatch a DM via dm.py.""" + cmd = [ + sys.executable, + str(DM_PY), + "send", + "--agent", sender, + "--to", recipient, + "--target", target, + text, + ] + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=90) + return res.returncode == 0, res.stdout.strip() or res.stderr.strip() + except Exception as e: + return False, str(e) + + +def sweep_cycle(dry_run=False): + followups = load_followups() + if not followups: + return {"status": "ok", "pending": 0, "nudges_sent": 0, "escalations": 0} + + now = utcnow_dt() + nudges_count = 0 + escalations_count = 0 + modified = False + + for dm_id, rec in list(followups.items()): + if rec.get("status") != "pending": + continue + + deadline_dt = parse_iso(rec.get("deadline")) + if not deadline_dt or now < deadline_dt: + continue + + # Deadline has expired! + nudges_sent = rec.get("nudges_sent", 0) + nudges_allowed = rec.get("nudges_allowed", 2) + sender = rec.get("sender", "opm") + recipient = rec.get("recipient") + orig_target = rec.get("target", "main") + thread_uuid = rec.get("thread_uuid") + + if nudges_sent < nudges_allowed: + # Deliver next nudge + nudge_num = nudges_sent + 1 + is_final = (nudge_num == nudges_allowed) + # Routing: In-thread first, Main on final nudge + delivery_target = "main" if is_final else orig_target + + nudge_text = ( + f"[nudge {nudge_num}/{nudges_allowed}] [ref:{dm_id}] " + f"Reminder: awaiting reply to request sent at {rec.get('sent_at', 'earlier')}." + ) + if is_final and orig_target != "main": + nudge_text += f" (Origin thread: {orig_target})" + + print(f"Sweeper: Sending nudge {nudge_num}/{nudges_allowed} to {recipient}/{delivery_target}...") + if not dry_run: + ok, out = send_dm(sender, recipient, delivery_target, nudge_text) + if ok: + nudges_count += 1 + rec["nudges_sent"] = nudge_num + rec["last_nudge_at"] = utcnow_str() + # Calculate interval for next nudge: proportional to timeout or default 10m + timeout_s = rec.get("timeout_s", 1800) + step_s = max(300, timeout_s // (nudges_allowed + 1)) + rec["deadline"] = (now + timedelta(seconds=step_s)).isoformat() + modified = True + append_job_log({ + "ts": utcnow_str(), + "type": "followup_nudged", + "dm_id": dm_id, + "nudge_num": nudge_num, + "recipient": recipient, + "target": delivery_target, + }) + else: + print(f"Sweeper WARNING: nudge send failed: {out}", file=sys.stderr) + else: + nudges_count += 1 + + else: + # All nudges exhausted: Terminal escalation + escalate_to = rec.get("escalate_to", "opm") + esc_text = ( + f"[ESCALATION] Agent {recipient} failed to reply to DM {dm_id} " + f"after {nudges_allowed} nudges. Target was: {orig_target} " + f"(thread: {thread_uuid or 'n/a'}). Request sent: {rec.get('sent_at')}." + ) + print(f"Sweeper: Escalating expired follow-up {dm_id} to {escalate_to}...") + if not dry_run: + ok, out = send_dm("bl", escalate_to, "main", esc_text) + rec["status"] = "escalated" + rec["escalated_at"] = utcnow_str() + escalations_count += 1 + modified = True + append_job_log({ + "ts": utcnow_str(), + "type": "followup_escalated", + "dm_id": dm_id, + "recipient": recipient, + "escalated_to": escalate_to, + }) + else: + escalations_count += 1 + + if modified and not dry_run: + save_followups(followups) + + pending_count = sum(1 for r in followups.values() if r.get("status") == "pending") + return { + "status": "ok", + "pending": pending_count, + "nudges_sent": nudges_count, + "escalations": escalations_count, + } + + +def main(): + parser = argparse.ArgumentParser(description="Autonomous follow-up deadline tracker and sweeper") + parser.add_argument("--once", action="store_true", help="Run once and exit (default)") + parser.add_argument("--loop", action="store_true", help="Run continuously in a daemon loop") + parser.add_argument("--interval", type=int, default=60, help="Interval in seconds for loop (default 60)") + parser.add_argument("--dry-run", action="store_true", help="Inspect without sending nudges or updating records") + args = parser.parse_args() + + if not args.loop: + stats = sweep_cycle(dry_run=args.dry_run) + print(f"[{datetime.now(timezone.utc).strftime('%H:%M:%SZ')}] Sweep cycle: {stats['pending']} pending, {stats['nudges_sent']} nudges, {stats['escalations']} escalations.") + return + + print(f"Starting follow-up sweeper loop (interval={args.interval}s)...") + while True: + try: + stats = sweep_cycle(dry_run=args.dry_run) + print(f"[{datetime.now(timezone.utc).strftime('%H:%M:%SZ')}] Sweep cycle: {stats['pending']} pending, {stats['nudges_sent']} nudges, {stats['escalations']} escalations.") + except Exception as e: + print(f"ERROR in sweeper loop: {e}", file=sys.stderr) + time.sleep(args.interval) + + +if __name__ == "__main__": + main() diff --git a/bin/job-dispatch.py b/bin/job-dispatch.py index 3398e4d..4a4acba 100755 --- a/bin/job-dispatch.py +++ b/bin/job-dispatch.py @@ -97,7 +97,7 @@ def load_job(job_name): sys.exit(1) print(f"Warning: YAML not supported (no PyYAML). Convert {job_file} to JSON.", file=sys.stderr) sys.exit(1) - + with open(job_file) as f: return json.load(f) @@ -109,25 +109,114 @@ def render_prompt(template, variables): result = result.replace(f"{{{key}}}", str(value)) return result -def send_dm(agent, target, message, dry_run=False): - """Send DM via dm.py""" +# ---- follow-up tracking (DM follow-up system integration) ---------------- +# Jobs opt in via a "followup" block in the job JSON: +# +# "followup": { +# "expect_reply": true, # required: enables tracking +# "timeout": "1h", # duration ("30s","15m","2h","1d") or seconds +# # int; default "1h" (3600s) +# "nudges": 2, # 0..10, default 2 +# "escalate": "opm", # identity string, default "opm" +# "route": "646-pip-coord" # optional route_id +# } +# +# The dispatcher translates this into dm.py --tag flags using the canonical +# vocabulary (box-threads/DEPLOY-DECISIONS.md). dm.py strips the tags from +# delivered text and creates a dm_followup request-store record after +# SENT+VERIFIED. Jobs without a followup block behave exactly as today. +# +# LIMITATIONS (v1): +# - The heartbeat job NEVER gets follow-ups (loopback health check). +# Hardcoded guard below; a followup block on heartbeat is ignored loudly. +# - Sidechat sends (muse-chat-api.py direct path) do not go through dm.py, +# so --tag flags cannot attach. v2 needs a record-creation path that does +# not send (e.g. POST /api/box/followups, or a bl->VM queue; bl cannot +# currently SSH to the VM). The dispatcher logs a warning when a +# sidechat-targeted job has followup enabled. + +HEARTBEAT_JOB_NAME = "heartbeat" + + +def parse_followup_duration(value): + """Parse a followup timeout into seconds. Accepts int (seconds) or + strings like '30s', '15m', '2h', '1d'. Returns int seconds. + Raises ValueError on bad input.""" + if isinstance(value, int) and not isinstance(value, bool): + s = value + elif isinstance(value, str): + m = re.fullmatch(r"(\d+)\s*([smhd])?", value.strip().lower()) + if not m: + raise ValueError("bad duration %r" % (value,)) + n = int(m.group(1)) + unit = m.group(2) or "s" + s = n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit] + else: + raise ValueError("timeout must be int seconds or duration string") + if not 60 <= s <= 604800: + raise ValueError("timeout must be 60..604800s (1m..7d), got %d" % s) + return s + + +def build_followup_tags(followup): + """Translate a job's followup block into dm.py --tag arguments. + Returns a flat list like ['--tag', 'reply:timeout=3600', ...]. + Returns [] if followup is falsy or expect_reply is not true. + Raises ValueError on invalid config (caller logs a warning and sends + the DM untagged -- the job itself must never fail over this).""" + if not followup or not followup.get("expect_reply"): + return [] + args = [] + # Bare trigger. dm.py's parse_tags splits each --tag on '='; an empty + # value means "present". If the deployed dm.py requires a non-empty + # value for this key, use 'reply:expected=true' instead. + args += ["--tag", "reply:expected="] + if "timeout" in followup: + s = parse_followup_duration(followup["timeout"]) + args += ["--tag", "reply:timeout=%d" % s] + if "nudges" in followup: + n = followup["nudges"] + if not isinstance(n, int) or isinstance(n, bool) or not 0 <= n <= 10: + raise ValueError("nudges must be int 0..10") + args += ["--tag", "reply:nudges=%d" % n] + if "escalate" in followup: + e = followup["escalate"] + if not isinstance(e, str) or not re.fullmatch(r"[a-z0-9_-]{1,64}", e): + raise ValueError("escalate must be an identity string") + args += ["--tag", "reply:escalate=%s" % e] + if "route" in followup: + r = followup["route"] + if not isinstance(r, str) or not re.fullmatch(r"[a-z0-9_-]{1,64}", r): + raise ValueError("route must be a route_id string") + args += ["--tag", "route:%s" % r] + # 'thread' is intentionally not settable from job JSON; it names a + # specific existing thread and is filled by the dispatcher when known. + return args + + +def send_dm(agent, target, message, dry_run=False, followup_tags=None): + """Send DM via dm.py. followup_tags: flat ['--tag', 'k=v', ...] list + from build_followup_tags(), or None.""" if dry_run: print(f"[DRY RUN] Would send to {agent} ({target}):") + if followup_tags: + print(f"[DRY RUN] With follow-up tags: {' '.join(followup_tags)}") print(message[:200] + "..." if len(message) > 200 else message) return "dry-run-id" - + # Rate limit if HAS_RATE_LIMITER: rate_limit_wait(agent) - - cmd = [str(DM_PY), "send", "--agent", "opm", "--to", agent, - "--target", target, message] + + cmd = ([str(DM_PY), "send", "--agent", "opm", "--to", agent, + "--target", target] + + (followup_tags or []) + [message]) result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) - + if result.returncode != 0: print(f"DM send failed: {result.stderr}", file=sys.stderr) return None - + # Extract message ID from output (format: SENT [id]) # dm.py prints the ID on success output = result.stdout.strip() @@ -140,7 +229,7 @@ def create_sidechat(sender_agent, dry_run=False): if dry_run: print(f"[DRY RUN] Would create sidechat for {sender_agent}") return True - + cmd = [NETVM_EXEC, sender_agent, "--", "python3", str(CHAT_API), "--account", sender_agent, "sidechat", "create"] try: @@ -166,10 +255,10 @@ def send_to_current_chat(sender_agent, message, dry_run=False): if dry_run: print(f"[DRY RUN] Would send to current chat: {message[:100]}...") return "dry-run-id" - + if HAS_RATE_LIMITER: rate_limit_wait(sender_agent) - + cmd = [NETVM_EXEC, sender_agent, "--", "python3", str(CHAT_API), "--account", sender_agent, "send", message] try: @@ -186,43 +275,68 @@ def main(): if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} [--dry-run]", file=sys.stderr) sys.exit(1) - + job_name = sys.argv[1] dry_run = "--dry-run" in sys.argv - + # Load job job = load_job(job_name) - + # Generate job_id job_id = f"{job_name}-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:8]}" - + # Variables for template variables = { "job_id": job_id, "job_name": job_name, "date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "datetime": datetime.now(timezone.utc).isoformat(), + "prev_job_id": os.environ.get("CHAIN_PREV_JOB_ID", ""), + "prev_result": os.environ.get("CHAIN_PREV_RESULT", ""), } - + + # Follow-up tracking (opt-in via job JSON "followup" block; see helpers). + # The heartbeat job is a loopback health check and must never be tracked. + followup_cfg = job.get("followup") + followup_tags = [] + if followup_cfg: + if job_name == HEARTBEAT_JOB_NAME: + print(f"Warning: job '{job_name}' must not use follow-up " + f"tracking (loopback); ignoring followup block", + file=sys.stderr) + log_event("job_followup_skipped", + {"job_id": job_id, "reason": "heartbeat_loopback"}) + else: + try: + followup_tags = build_followup_tags(followup_cfg) + if followup_tags: + log_event("job_followup_armed", + {"job_id": job_id, "tags": followup_tags}) + except ValueError as e: + print(f"Warning: invalid followup block: {e}; " + f"sending untagged", file=sys.stderr) + log_event("job_followup_invalid", + {"job_id": job_id, "error": str(e)}) + # Render prompt prompt_template = job.get("prompt_template", "") if not prompt_template: print(f"Error: Job '{job_name}' has no prompt_template", file=sys.stderr) sys.exit(1) - + rendered = render_prompt(prompt_template, variables) - + # Format as JOB DM dm_message = f"[JOB {job_id}] {rendered}" - + # Get target agent = job.get("agent", "muse") sidechat_cfg = job.get("sidechat", {}) sidechat_url = None - + use_sidechat = sidechat_cfg.get("create", False) sidechat_created = False - + if use_sidechat: reuse_key = sidechat_cfg.get("reuse_key") name_tmpl = sidechat_cfg.get("name_template", "job-{job_name}-{date}") @@ -262,7 +376,12 @@ def main(): capture_uuid = False else: target = "main" - + # dm_target override: job JSON can specify a dm.py --target + # (sidechat name/UUID) for tracked sends to a thread. + _dt = job.get("dm_target") + if _dt and isinstance(_dt, str) and _dt.strip(): + target = _dt.strip() + # Log job_sent log_event("job_sent", { "job_id": job_id, @@ -271,7 +390,7 @@ def main(): "target": target, "dry_run": dry_run, }) - + # Send DM: to sidechat via direct API, or to main via dm.py if use_sidechat and sidechat_created: msg_id = send_to_current_chat("opm", dm_message, dry_run=dry_run) @@ -297,12 +416,23 @@ def main(): log_event("job_sidechat_mapped", {"reuse_key": reuse_key, "thread_uuid": thread_uuid}) print(f"Mapped reuse_key {reuse_key} -> {thread_uuid}", file=sys.stderr) break + if followup_tags and not dry_run: + # v1 limitation: sidechat sends bypass dm.py, so --tag flags + # cannot attach and no dm_followup record is created. The + # job is still dispatched; tracking is skipped loudly. + print(f"Warning: follow-up tracking not supported for " + f"sidechat sends (v1); job {job_id} dispatched " + f"without tracking", file=sys.stderr) + log_event("job_followup_skipped", + {"job_id": job_id, + "reason": "sidechat_path_v1"}) # Skip the dm.py dispatch block below import sys as _sys2 _sys2.exit(0) else: - msg_id = send_dm(agent, target, dm_message, dry_run=dry_run) - + msg_id = send_dm(agent, target, dm_message, + dry_run=dry_run, followup_tags=followup_tags) + if msg_id and not dry_run: print(f"Dispatched job {job_id} to {agent} (DM: {msg_id})") log_event("job_dispatched", { diff --git a/bin/response-harvester.py b/bin/response-harvester.py new file mode 100755 index 0000000..43776f5 --- /dev/null +++ b/bin/response-harvester.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +""" +response-harvester.py — Fleet agent readback and response harvesting daemon. + +Monitors Chromebox agents (muse, pip, 646, opm), harvests incoming messages from +Main Chat and registered sidechats, maintains persistent watermarks, appends to +chat-history.jsonl, resolves pending follow-ups, and records [RESULT] completions +in job-log.jsonl. + +Features: +- Direct CDP over host veth interfaces (fast, no sudo needed). +- cdp_queue integration with PRIORITY_LOW (never blocks operator/DMs). +- URL state preservation (restores browser to initial thread/main via Ctrl+J). +- Bounded scroll-back for virtualized DOM (#hatch-chat-scroll). +- Per-node fault isolation (CDP errors on one node do not abort the cycle). +- Dual-mode execution (--once for systemd timers/CLI, --loop for daemon). + +Usage: + python3 response-harvester.py --once + python3 response-harvester.py --loop --interval 30 + python3 response-harvester.py --agent 646 --once +""" + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.request +import websocket +from datetime import datetime, timezone +from pathlib import Path + +# Paths +NETVM_ROOT = Path("/home/super/Projects/NetVM") +BIN_DIR = NETVM_ROOT / "bin" +LOGS_DIR = NETVM_ROOT / "logs" +CHAT_HISTORY_LOG = LOGS_DIR / "chat-history.jsonl" +WATERMARKS_FILE = NETVM_ROOT / "siphon-watermarks.json" +JOB_LOG = NETVM_ROOT / "job-log.jsonl" +FOLLOWUPS_FILE = NETVM_ROOT / "followups.json" +JOB_SIDECHATS_FILE = NETVM_ROOT / "job-sidechats.json" +WAKE_SIDECHATS_FILE = Path("/home/super/sidechat-wake/wake-sidechats.json") +JOBS_DIR = NETVM_ROOT / "jobs" +DISPATCH_PY = BIN_DIR / "job-dispatch.py" + +# Ensure bin is in sys.path +sys.path.insert(0, str(BIN_DIR)) +try: + from cdp_queue import cdp_slot, PRIORITY_LOW + HAS_CDP_QUEUE = True +except ImportError: + HAS_CDP_QUEUE = False + +try: + import netvm_registry + HAS_REGISTRY = True +except ImportError: + HAS_REGISTRY = False + +VALID_AGENTS = ["muse", "pip", "646", "opm"] +DEFAULT_PORTS = {"muse": 9410, "pip": 9420, "646": 9430, "opm": 9440} + + +def utcnow(): + return datetime.now(timezone.utc).isoformat() + + +def get_node_network(node): + """Derive veth peer IP and CDP port from node identity.""" + tag = hashlib.sha256(node.encode()).hexdigest()[:8] + idx = int(tag[:3], 16) % 200 + 10 + peer_ip = f"10.201.{idx}.2" + + port = None + if HAS_REGISTRY: + try: + port = netvm_registry.port_for(node) + except Exception: + pass + if not port: + port = DEFAULT_PORTS.get(node, 9410) + + return peer_ip, port + + +def load_json_file(path, default=None): + if default is None: + default = {} + if not os.path.exists(path): + return default + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return default + + +def save_json_file(path, data): + tmp_path = f"{path}.tmp.{os.getpid()}" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp_path, path) + + +def append_jsonl(path, record): + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(record) + "\n") + + +def get_monitored_threads(target_agent=None): + """ + Build dict of threads to monitor per agent: + { agent: [ {"id": "main", "name": "main"}, {"id": "", "name": ""} ] } + """ + agents = [target_agent] if target_agent else VALID_AGENTS + threads_by_agent = {a: [{"id": "main", "name": "Main Chat"}] for a in agents} + + state_files = [JOB_SIDECHATS_FILE, WAKE_SIDECHATS_FILE] + for sf in state_files: + if not sf.exists(): + continue + try: + data = json.loads(sf.read_text(encoding="utf-8")) + for key, val in data.items(): + if key.startswith("_"): + continue + if isinstance(val, dict): + uuid = val.get("thread_uuid") or val.get("uuid") + agent = val.get("agent", "opm") + elif isinstance(val, str): + uuid = val + agent = "opm" + else: + continue + + if uuid and agent in threads_by_agent: + # Avoid duplicate threads + existing = [t["id"] for t in threads_by_agent[agent]] + if uuid not in existing: + threads_by_agent[agent].append({"id": uuid, "name": key}) + except Exception: + continue + + return threads_by_agent + + +class CDPClient: + """Lightweight direct CDP client over host veth.""" + + def __init__(self, node, peer_ip, port, timeout=10): + self.node = node + self.peer_ip = peer_ip + self.port = port + self.timeout = timeout + self.ws = None + self.msg_id = 0 + + def connect(self): + url = f"http://{self.peer_ip}:{self.port}/json/list" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + targets = json.load(resp) + pages = [t for t in targets if t.get("type") == "page"] + if not pages: + raise RuntimeError(f"No page target found on CDP for {self.node}") + ws_url = pages[0]["webSocketDebuggerUrl"] + self.ws = websocket.create_connection(ws_url, timeout=self.timeout) + + def send_cmd(self, method, params=None): + self.msg_id += 1 + cid = self.msg_id + payload = {"id": cid, "method": method, "params": params or {}} + self.ws.send(json.dumps(payload)) + while True: + raw = self.ws.recv() + data = json.loads(raw) + if data.get("id") == cid: + return data + + def evaluate(self, expr, await_promise=False): + res = self.send_cmd( + "Runtime.evaluate", + {"expression": expr, "returnByValue": True, "awaitPromise": await_promise}, + ) + result = res.get("result", {}).get("result", {}) + if res.get("result", {}).get("exceptionDetails"): + desc = res["result"]["exceptionDetails"].get("text", "JS exception") + raise RuntimeError(f"CDP eval error: {desc}") + return result.get("value") + + def dispatch_key(self, key, code, modifiers=0): + self.send_cmd( + "Input.dispatchKeyEvent", + { + "type": "rawKeyDown", + "key": key, + "code": code, + "modifiers": modifiers, + "windowsVirtualKeyCode": 74 if code == "KeyJ" else 0, + }, + ) + self.send_cmd( + "Input.dispatchKeyEvent", + { + "type": "keyUp", + "key": key, + "code": code, + "modifiers": modifiers, + "windowsVirtualKeyCode": 74 if code == "KeyJ" else 0, + }, + ) + + def close(self): + if self.ws: + try: + self.ws.close() + except Exception: + pass + self.ws = None + + +DOM_EXTRACT_JS = """(() => { + const els = [...document.querySelectorAll('[data-message-id]')]; + return els.map(m => { + const id = m.getAttribute('data-message-id'); + const ps = [...m.querySelectorAll('p')].map(p => (p.innerText || '').trim()).filter(Boolean); + let text = ps.join('\\n'); + if (!text) { + text = (m.innerText || '').replace(/^(Assistant message:|User message:)\\s*/i, '').trim(); + } + const t = m.querySelector('time'); + return { + id: id, + author: id.startsWith('assistant-msg') ? 'assistant' : 'user', + text: text, + ts: t ? (t.getAttribute('datetime') || t.innerText || null) : null + }; + }); +})()""" + + +def scrape_thread_messages(cdp, thread_id, watermark, max_scrollbacks=3): + """Scrape messages with bounded scroll-back if watermark is out of view.""" + messages = cdp.evaluate(DOM_EXTRACT_JS) or [] + + # If watermark exists and is already in view, or no watermark, no scroll-back needed + seen_ids = {m["id"] for m in messages if m.get("id")} + if watermark and watermark not in seen_ids and max_scrollbacks > 0: + # Bounded scroll-back loop + for _ in range(max_scrollbacks): + cdp.evaluate("""(() => { + const sc = document.getElementById('hatch-chat-scroll'); + if (sc) sc.scrollTop = 0; + })()""") + time.sleep(0.8) + older = cdp.evaluate(DOM_EXTRACT_JS) or [] + for m in older: + if m.get("id") and m["id"] not in seen_ids: + messages.insert(0, m) + seen_ids.add(m["id"]) + if watermark in seen_ids: + break + + return messages + + +def harvest_agent_thread(cdp, agent, thread_info, watermarks, followups, dry_run=False): + """ + Harvests new messages for a single thread, preserves URL state, + and returns (new_messages, new_watermark, job_results_count). + """ + thread_id = thread_info["id"] + thread_name = thread_info["name"] + wm_key = f"{agent}:{thread_id}" + last_wm = watermarks.get(wm_key, "") + + # 1. Capture current URL before navigating + initial_url = cdp.evaluate("window.location.href") or "https://muse.ai/" + is_init_main = "/thread/" not in initial_url + + # 2. Navigate to target thread if not already there + try: + if thread_id == "main": + if not is_init_main: + # Dispatch Ctrl+J (modifier 2 = Control) + cdp.dispatch_key("j", "KeyJ", modifiers=2) + time.sleep(2.0) + else: + target_url = f"https://muse.ai/thread/{thread_id}" + if initial_url.strip() != target_url: + cdp.evaluate(f"window.location.href = {json.dumps(target_url)}") + # Settle wait + time.sleep(2.5) + + # 3. Scrape messages + raw_messages = scrape_thread_messages(cdp, thread_id, last_wm) + finally: + # 4. State preservation: restore browser back to initial state + try: + curr_url = cdp.evaluate("window.location.href") or "" + if is_init_main: + if "/thread/" in curr_url: + cdp.dispatch_key("j", "KeyJ", modifiers=2) + else: + if curr_url.strip() != initial_url.strip(): + cdp.evaluate(f"window.location.href = {json.dumps(initial_url)}") + except Exception: + pass + + if not raw_messages: + return [], last_wm, 0 + + # 5. Filter for new messages based on watermark + new_messages = [] + if not last_wm: + # Initial run on this thread: watermark at current latest message to avoid flooding backlog + new_wm = raw_messages[-1]["id"] + return [], new_wm, 0 + else: + # Find index of last_wm + wm_idx = -1 + for i, m in enumerate(raw_messages): + if m["id"] == last_wm: + wm_idx = i + break + if wm_idx >= 0: + new_messages = raw_messages[wm_idx + 1 :] + else: + # Watermark not found in loaded window (older than scroll limit) + # Process all visible messages that are newer than timestamp or just unread tail + new_messages = raw_messages + + if not new_messages: + return [], last_wm, 0 + + new_wm = new_messages[-1]["id"] + job_results = 0 + + # 6. Ingest new messages + for msg in new_messages: + mid = msg.get("id", "") + author = msg.get("author", "unknown") + text = msg.get("text", "") + msg_ts = msg.get("ts") or utcnow() + + # Append to chat-history.jsonl + record = { + "ts": utcnow(), + "agent": agent, + "thread_id": thread_id, + "thread_name": thread_name, + "msg_id": mid, + "author": author, + "text": text, + "source_ts": msg_ts, + } + if not dry_run: + append_jsonl(CHAT_HISTORY_LOG, record) + + # Check for [RESULT ] in assistant messages + if author == "assistant": + m_res = re.search(r"\[RESULT\s+([A-Za-z0-9_-]+)\]\s*(.*)", text, re.S) + if m_res: + job_id = m_res.group(1).strip() + result_text = m_res.group(2).strip() + is_fail = result_text.startswith("FAILED") or result_text.startswith("UNABLE") or result_text.startswith("FAIL") + job_results += 1 + + job_record = { + "ts": utcnow(), + "type": "job_result", + "job_id": job_id, + "agent": agent, + "success": not is_fail, + "result_snippet": result_text[:300], + "thread_id": thread_id, + "msg_id": mid, + } + if not dry_run: + append_jsonl(JOB_LOG, job_record) + # Trigger chain_next if configured + trigger_chain_next(job_id, result_text) + + # Check and clear pending follow-ups + clear_matching_followups(followups, agent, thread_id, mid, text, dry_run) + + return new_messages, new_wm, job_results + + +def trigger_chain_next(job_id, result_text): + """If the completed job has a chain_next property, dispatch it with context.""" + # Job ID format: -- + parts = job_id.split("-") + if len(parts) < 3: + return + job_name = "-".join(parts[:-2]) + job_file = JOBS_DIR / f"{job_name}.json" + if not job_file.exists(): + return + + try: + with open(job_file, "r", encoding="utf-8") as f: + cfg = json.load(f) + chain_next = cfg.get("chain_next") + if chain_next and (JOBS_DIR / f"{chain_next}.json").exists(): + env = os.environ.copy() + env["CHAIN_PREV_JOB_ID"] = job_id + env["CHAIN_PREV_RESULT"] = result_text[:1000] + subprocess.Popen( + [sys.executable, str(DISPATCH_PY), chain_next], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + pass + + +def clear_matching_followups(followups, agent, thread_id, mid, text, dry_run=False): + """Resolve follow-up records if an assistant message is detected in the thread.""" + if not followups: + return + + modified = False + for f_id, f_rec in followups.items(): + if f_rec.get("status") != "pending": + continue + if f_rec.get("recipient") != agent: + continue + + # Match either exact thread_uuid, or target alias 'main' + match_thread = False + if f_rec.get("target") == "main" and thread_id == "main": + match_thread = True + elif f_rec.get("thread_uuid") and f_rec.get("thread_uuid") == thread_id: + match_thread = True + + if match_thread: + f_rec["status"] = "resolved" + f_rec["resolved_at"] = utcnow() + f_rec["resolved_by_mid"] = mid + f_rec["resolved_snippet"] = text[:150] + modified = True + + if modified and not dry_run: + save_json_file(FOLLOWUPS_FILE, followups) + + +def harvest_cycle(target_agent=None, dry_run=False, output_json=False): + """Execute one full harvest cycle across agents and threads.""" + watermarks = load_json_file(WATERMARKS_FILE) + followups = load_json_file(FOLLOWUPS_FILE) + monitored = get_monitored_threads(target_agent) + + cycle_stats = { + "timestamp": utcnow(), + "agents": {}, + "total_new_messages": 0, + "total_job_results": 0, + } + + for agent, thread_list in monitored.items(): + agent_stats = {"status": "ok", "threads": {}, "new_messages": 0, "job_results": 0} + peer_ip, port = get_node_network(agent) + + # Wrap in CDP slot if available + slot_ctx = ( + cdp_slot(agent, priority=PRIORITY_LOW, timeout=10) + if HAS_CDP_QUEUE + else None + ) + + try: + if slot_ctx: + with slot_ctx: + cdp = CDPClient(agent, peer_ip, port, timeout=8) + cdp.connect() + try: + for t_info in thread_list: + new_msgs, new_wm, j_res = harvest_agent_thread( + cdp, agent, t_info, watermarks, followups, dry_run + ) + wm_key = f"{agent}:{t_info['id']}" + if new_wm and not dry_run: + watermarks[wm_key] = new_wm + agent_stats["threads"][t_info["name"]] = len(new_msgs) + agent_stats["new_messages"] += len(new_msgs) + agent_stats["job_results"] += j_res + finally: + cdp.close() + else: + cdp = CDPClient(agent, peer_ip, port, timeout=8) + cdp.connect() + try: + for t_info in thread_list: + new_msgs, new_wm, j_res = harvest_agent_thread( + cdp, agent, t_info, watermarks, followups, dry_run + ) + wm_key = f"{agent}:{t_info['id']}" + if new_wm and not dry_run: + watermarks[wm_key] = new_wm + agent_stats["threads"][t_info["name"]] = len(new_msgs) + agent_stats["new_messages"] += len(new_msgs) + agent_stats["job_results"] += j_res + finally: + cdp.close() + + except Exception as e: + agent_stats["status"] = "error" + agent_stats["error"] = str(e)[:200] + + cycle_stats["agents"][agent] = agent_stats + cycle_stats["total_new_messages"] += agent_stats["new_messages"] + cycle_stats["total_job_results"] += agent_stats["job_results"] + + if not dry_run: + save_json_file(WATERMARKS_FILE, watermarks) + + # Output formatting + if output_json: + print(json.dumps(cycle_stats)) + else: + ts_short = cycle_stats["timestamp"].split("T")[1][:8] + summary_parts = [] + for ag, st in cycle_stats["agents"].items(): + if st["status"] == "ok": + summary_parts.append(f"{ag}: {st['new_messages']} msgs ({st['job_results']} results)") + else: + summary_parts.append(f"{ag}: [UNREACHABLE: {st.get('error', 'err')[:40]}]") + print(f"[{ts_short}Z] Harvest cycle: {', '.join(summary_parts)}") + + return cycle_stats + + +def main(): + parser = argparse.ArgumentParser(description="Fleet agent readback and response harvester") + parser.add_argument("--once", action="store_true", help="Run once and exit (default)") + parser.add_argument("--loop", action="store_true", help="Run continuously in a daemon loop") + parser.add_argument("--interval", type=int, default=30, help="Interval in seconds for --loop (default 30)") + parser.add_argument("--agent", choices=VALID_AGENTS, default=None, help="Harvest only specific agent") + parser.add_argument("--dry-run", action="store_true", help="Scrape without persisting watermarks or logs") + parser.add_argument("--json", action="store_true", help="Output summary as JSON") + args = parser.parse_args() + + # Default to --once if --loop is not provided + if not args.loop: + harvest_cycle(target_agent=args.agent, dry_run=args.dry_run, output_json=args.json) + return + + print(f"Starting response-harvester daemon (interval={args.interval}s, agent={args.agent or 'all'})...") + while True: + try: + harvest_cycle(target_agent=args.agent, dry_run=args.dry_run, output_json=args.json) + except Exception as e: + print(f"ERROR in harvest loop: {e}", file=sys.stderr) + time.sleep(args.interval) + + +if __name__ == "__main__": + main() diff --git a/bin/super-cli.py b/bin/super-cli.py new file mode 100755 index 0000000..d393740 --- /dev/null +++ b/bin/super-cli.py @@ -0,0 +1,2957 @@ +#!/usr/bin/env python3 +"""super-cli.py — Unified Off-Board Orchestrator CLI for NetVM & Box. + +Empowers 'off-board super' to govern, task, and observe the fleet of +autonomous browser agents (muse, pip, 646, opm) on bl and interface +with the Google Cloud VM web surfaces. + +Domains: + super fleet — Node health, CDP status, active tabs, watch, restart + super dm — Inter-agent DMs, work orders ([WO]), acks, live log tail + super thread — Inspect agent main chats, sidechats, and scrollbacks + super job — Manage scheduled jobs, systemd timers, triggers, logs + super web — Probe VM web surfaces (box.muse-dev.online), auth, sync + super loop — Intrinsic loop strategy, fleet loop health, break taxonomy, vars + super strat — Shortcut to inspect & configure loop modulation strategies + super vars — Shortcut to inspect & adjust runtime control variables + +Usage: + super fleet [status|watch|restart|cdp] + super dm [send|wo|ack|log|tail] + super thread [list|view] + super job [list|status|run|log|create|enable|disable|delete] + super web [health|test-auth|sync] + super loop [status|health|breaks|strat|vars|close|nudge|sweep|harvest|remediate] + super strat [show|set|reset|eval] + super vars [list|get|set|reset|history|rollback] +""" + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +# Paths +NETVM_ROOT = Path("/home/super/Projects/NetVM") +BIN_DIR = NETVM_ROOT / "bin" +JOBS_DIR = NETVM_ROOT / "jobs" +TRANSFERS_DIR = NETVM_ROOT / "transfers" +DM_LOG = NETVM_ROOT / "dm-log.jsonl" +JOB_LOG = NETVM_ROOT / "job-log.jsonl" +CTL_LOG = NETVM_ROOT / "box-ctl.jsonl" +REGISTRY_FILE = NETVM_ROOT / "NODES.md" +CHAT_HISTORY_LOG = NETVM_ROOT / "logs" / "chat-history.jsonl" +WATERMARKS_FILE = NETVM_ROOT / "siphon-watermarks.json" +FOLLOWUPS_FILE = NETVM_ROOT / "followups.json" +JOB_SIDECHATS_FILE = NETVM_ROOT / "job-sidechats.json" +RESPONSE_HARVESTER_PY = BIN_DIR / "response-harvester.py" +FOLLOWUP_SWEEPER_PY = BIN_DIR / "followup-sweeper.py" + +# Agent Constants +VALID_NODES = ["muse", "pip", "646", "opm"] +DEFAULT_SENDER = "super" +DEFAULT_AGENT_SIDECHATS = { + "646": "646 tasks", + "opm": "heartbeat", + "pip": "646-pip-coord", + "muse": "main", +} + +# --------------------------------------------------------------------------- +# ANSI Color & Formatting Engine +# --------------------------------------------------------------------------- +USE_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None + +def _c(code: str, text: str) -> str: + return f"\033[{code}m{text}\033[0m" if USE_COLOR else str(text) + +def c_bold(s: str) -> str: return _c("1", s) +def c_dim(s: str) -> str: return _c("2", s) +def c_green(s: str) -> str: return _c("32", s) +def c_red(s: str) -> str: return _c("31", s) +def c_yellow(s: str) -> str: return _c("33", s) +def c_blue(s: str) -> str: return _c("34", s) +def c_magenta(s: str) -> str: return _c("35", s) +def c_cyan(s: str) -> str: return _c("36", s) + +def badge_ok(s: str = "UP") -> str: return c_green(f"● {s}") +def badge_warn(s: str = "WARN") -> str: return c_yellow(f"● {s}") +def badge_err(s: str = "DOWN") -> str: return c_red(f"● {s}") +def badge_dim(s: str) -> str: return c_dim(f"○ {s}") + +def print_table(headers: list, rows: list, alignments: list = None): + """Print an aligned tabular display with clean ANSI formatting.""" + if not rows: + print(c_dim(" (no records found)")) + return + + num_cols = len(headers) + alignments = alignments or ["left"] * num_cols + + # Calculate max visual length per column (stripping ANSI escapes) + strip_ansi = re.compile(r"\033\[[0-9;]*m") + + def vlen(s): + return len(strip_ansi.sub("", str(s))) + + col_widths = [vlen(h) for h in headers] + for row in rows: + for i in range(num_cols): + val = str(row[i]) if i < len(row) else "" + col_widths[i] = max(col_widths[i], vlen(val)) + + # Header + hdr_parts = [] + sep_parts = [] + for i, h in enumerate(headers): + w = col_widths[i] + align = alignments[i] + pad = w - vlen(h) + if align == "right": + hdr_parts.append(" " * pad + c_bold(h)) + else: + hdr_parts.append(c_bold(h) + " " * pad) + sep_parts.append("─" * w) + + print(" " + " ".join(hdr_parts)) + print(" " + c_dim(" ".join(sep_parts))) + + # Rows + for row in rows: + row_parts = [] + for i in range(num_cols): + val = str(row[i]) if i < len(row) else "" + w = col_widths[i] + align = alignments[i] + pad = max(0, w - vlen(val)) + if align == "right": + row_parts.append(" " * pad + val) + else: + row_parts.append(val + " " * pad) + print(" " + " ".join(row_parts)) + +def parse_relative_time(ts_str: str) -> str: + """Convert ISO timestamp to short relative time (e.g. 5m ago, 2h ago).""" + if not ts_str: + return "-" + try: + dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + diff = int((now - dt).total_seconds()) + if diff < 0: + return "just now" + if diff < 60: + return f"{diff}s ago" + if diff < 3600: + return f"{diff // 60}m ago" + if diff < 86400: + return f"{diff // 3600}h ago" + return f"{diff // 86400}d ago" + except Exception: + return ts_str[:16].replace("T", " ") + +# --------------------------------------------------------------------------- +# Registry & Network Helpers +# --------------------------------------------------------------------------- +def get_node_network_info(node: str) -> dict: + """Compute deterministic veth/peer IP and load pinned CDP port.""" + tag = hashlib.sha256(node.encode()).hexdigest()[:8] + idx = int(tag[:3], 16) % 200 + 10 + gw_ip = f"10.201.{idx}.1" + peer_ip = f"10.201.{idx}.2" + + # Pinned ports from NODES.md registry + pinned_ports = { + "muse": 9410, + "pip": 9420, + "646": 9430, + "opm": 9440, + } + cdp_port = pinned_ports.get(node, 9222 + int(tag[4:7], 16) % 2000) + + return { + "node": node, + "netns": f"warp-{node}", + "tag": tag, + "gw_ip": gw_ip, + "peer_ip": peer_ip, + "cdp_port": cdp_port, + } + +def probe_cdp_status(peer_ip: str, cdp_port: int, timeout: float = 1.5) -> dict: + """Probe Chromium DevTools protocol on peer IP.""" + url = f"http://{peer_ip}:{cdp_port}/json/version" + t0 = time.time() + try: + req = urllib.request.Request(url, headers={"User-Agent": "super-cli/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + elapsed_ms = int((time.time() - t0) * 1000) + if resp.status == 200: + data = json.load(resp) + return { + "ok": True, + "latency_ms": elapsed_ms, + "browser": data.get("Browser", "Chromium"), + } + except Exception as e: + return {"ok": False, "error": str(e), "latency_ms": None} + return {"ok": False, "error": "unknown error", "latency_ms": None} + +def fetch_active_tab(peer_ip: str, cdp_port: int, timeout: float = 1.5) -> dict: + """Retrieve title and URL of the active browser page.""" + url = f"http://{peer_ip}:{cdp_port}/json/list" + try: + req = urllib.request.Request(url, headers={"User-Agent": "super-cli/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + tabs = json.load(resp) + pages = [t for t in tabs if t.get("type") == "page"] + if pages: + return { + "title": pages[0].get("title", ""), + "url": pages[0].get("url", ""), + } + except Exception: + pass + return {"title": "-", "url": "-"} + +def check_process_alive(node: str) -> bool: + """Check if Chromium process exists for the node's profile.""" + try: + res = subprocess.run( + ["pgrep", "-f", f"chrome-box/profiles/{node}"], + capture_output=True, text=True, timeout=2 + ) + return res.returncode == 0 + except Exception: + return False + +def get_queue_depth(node: str) -> int: + """Check active flock tickets in /tmp/cdp-queue//.""" + q_dir = Path(f"/tmp/cdp-queue/{node}") + if not q_dir.exists(): + return 0 + try: + tickets = [f for f in q_dir.iterdir() if f.name.startswith("ticket-")] + return len(tickets) + except Exception: + return 0 + +# --------------------------------------------------------------------------- +# Domain: FLEET +# --------------------------------------------------------------------------- +def collect_fleet_data() -> list: + results = [] + for node in VALID_NODES: + info = get_node_network_info(node) + proc_alive = check_process_alive(node) + cdp = probe_cdp_status(info["peer_ip"], info["cdp_port"]) + tab = fetch_active_tab(info["peer_ip"], info["cdp_port"]) if cdp["ok"] else {"title": "-", "url": "-"} + q_depth = get_queue_depth(node) + + results.append({ + "node": node, + "netns": info["netns"], + "peer_ip": info["peer_ip"], + "cdp_port": info["cdp_port"], + "proc_alive": proc_alive, + "cdp_ok": cdp["ok"], + "latency_ms": cdp["latency_ms"], + "title": tab["title"], + "url": tab["url"], + "queue_depth": q_depth, + }) + return results + +def cmd_fleet_status(args): + data = collect_fleet_data() + if args.json: + print(json.dumps({"ok": True, "fleet": data}, indent=2)) + return + + print("\n" + c_bold("=== NETVM FLEET STATUS ===") + c_dim(f" ({datetime.now().strftime('%H:%M:%S')} local)\n")) + + headers = ["NODE", "STATUS", "PEER IP:PORT", "LATENCY", "QUEUE", "ACTIVE PAGE / THREAD"] + rows = [] + for item in data: + # Status calculation + if item["proc_alive"] and item["cdp_ok"]: + status = badge_ok("ACTIVE") + elif item["proc_alive"] and not item["cdp_ok"]: + status = badge_warn("CDP_DOWN") + else: + status = badge_err("STOPPED") + + lat = f"{item['latency_ms']}ms" if item["latency_ms"] is not None else "-" + q = str(item["queue_depth"]) if item["queue_depth"] > 0 else c_dim("idle") + + # Format title/URL nicely + title = item["title"] + if "thread/" in item["url"]: + m = re.search(r"thread/([0-9a-fA-F-]+)", item["url"]) + if m: + uuid_short = m.group(1)[:8] + title = f"{title} [{c_cyan(uuid_short)}]" + elif item["url"] == "https://muse.ai/": + title = f"{title} [{c_dim('home')}]" + + rows.append([ + c_bold(item["node"]), + status, + f"{item['peer_ip']}:{item['cdp_port']}", + lat, + q, + title[:45] + ]) + + print_table(headers, rows) + print("\n" + c_dim(" Commands: super fleet watch | super fleet restart | super fleet cdp ") + "\n") + +def cmd_fleet_watch(args): + interval = getattr(args, "interval", 2) + try: + while True: + # Clear terminal + sys.stdout.write("\033[2J\033[H") + sys.stdout.flush() + cmd_fleet_status(args) + print(c_dim(f" [Auto-refreshing every {interval}s. Press Ctrl+C to exit]")) + time.sleep(interval) + except KeyboardInterrupt: + print("\n" + c_dim("Exited watch mode.")) + +def cmd_fleet_restart(args): + node = args.node + if node not in VALID_NODES: + print(f"Error: Unknown node '{node}'. Valid: {', '.join(VALID_NODES)}", file=sys.stderr) + sys.exit(1) + + print(f"Restarting node '{c_bold(node)}' via netvm-node-up.sh...") + cmd = ["sudo", str(BIN_DIR / "netvm-node-up.sh"), node] + res = subprocess.run(cmd, text=True) + if res.returncode == 0: + print(c_green(f"Node '{node}' brought up successfully.")) + else: + print(c_red(f"Node restart exited with code {res.returncode}.")) + sys.exit(res.returncode) + +def cmd_fleet_cdp(args): + node = args.node + if node not in VALID_NODES: + print(f"Error: Unknown node '{node}'", file=sys.stderr) + sys.exit(1) + + info = get_node_network_info(node) + peer = info["peer_ip"] + port = info["cdp_port"] + + if args.json: + print(json.dumps({ + "node": node, + "peer_ip": peer, + "port": port, + "url": f"http://{peer}:{port}", + "ssh_forward": f"ssh -L {port}:{peer}:{port} super@100.123.153.75" + }, indent=2)) + return + + print(f"\n{c_bold('CDP Access for ' + node)}:") + print(f" Host Endpoint : {c_cyan(f'http://{peer}:{port}/json/version')}") + print(f" SSH Forward : {c_yellow(f'ssh -L {port}:{peer}:{port} super@100.123.153.75')}") + print(f" Local Connect : {c_dim(f'http://127.0.0.1:{port}')} (after forwarding)\n") + +def resolve_sender(args) -> str: + explicit = getattr(args, "from_agent", None) + if explicit and explicit != DEFAULT_SENDER: + return explicit + caller_env = os.environ.get("BOX_CALLER") + if caller_env and caller_env in ("muse", "pip", "646", "opm", "super"): + return caller_env + return explicit or DEFAULT_SENDER + +def sign_message(sender: str, message: str, key_path: Path = None, msg_id: str = None) -> tuple: + """Signs message with SSH key (namespace 'dm') using ssh-keygen -Y sign. + Returns (signed_raw_wire_format, msg_id).""" + import tempfile + key_path = key_path or Path.home() / ".ssh" / "id_ed25519" + if not key_path.exists(): + raise FileNotFoundError(f"Signing key not found: {key_path}") + + mid = msg_id or hashlib.sha256(f"{sender}-{time.time()}-{os.urandom(8).hex()}".encode()).hexdigest()[:8] + payload = f"[from:{sender}] [id:{mid}]\n\n{message}" + + with tempfile.TemporaryDirectory() as td: + payf = Path(td) / "payload" + with open(payf, "w") as f: + f.write(payload) + + res = subprocess.run( + ["ssh-keygen", "-Y", "sign", "-f", str(key_path), "-n", "dm", str(payf)], + capture_output=True, text=True, timeout=10 + ) + if res.returncode != 0: + raise RuntimeError(f"ssh-keygen sign failed: {res.stderr}") + + sigf = Path(td) / "payload.sig" + with open(sigf, "r") as f: + sigblock = f.read().strip() + + signed_wire = f"[from:{sender}] [id:{mid}]\n\n{message}\n\n{sigblock}" + return signed_wire, mid + +# --------------------------------------------------------------------------- +# Domain: DM (Direct Messaging & Work Orders) +# --------------------------------------------------------------------------- +def wait_for_reply(recipient: str, target: str, msg_id: str = None, timeout: int = 60) -> str: + """Wait for recipient agent to reply, displaying an animated spinner.""" + spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + t0 = time.time() + idx = 0 + dm_pos = DM_LOG.stat().st_size if DM_LOG.exists() else 0 + found_reply = None + chat_hist_file = NETVM_ROOT / "logs" / "chat-history.jsonl" + hist_pos = chat_hist_file.stat().st_size if chat_hist_file.exists() else 0 + + sys.stdout.write("\n") + try: + while time.time() - t0 < timeout: + spin = spinner_chars[idx % len(spinner_chars)] + elapsed = int(time.time() - t0) + sys.stdout.write(f"\r {c_cyan(spin)} Awaiting response from {c_bold(recipient)}/{target}... ({elapsed}s/{timeout}s) [Ctrl+C to stop waiting]") + sys.stdout.flush() + idx += 1 + + # 1. Check DM_LOG for return message from recipient + if DM_LOG.exists() and DM_LOG.stat().st_size > dm_pos: + with open(DM_LOG, "r") as f: + f.seek(dm_pos) + for line in f: + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + ev_type = d.get("type", "") + sender = d.get("agent", "") + if sender == recipient and ev_type in ("sent", "send_done", "verified"): + m = d.get("msg") or d.get("body") + if m and (not msg_id or msg_id not in m): + found_reply = m + break + except Exception: + pass + dm_pos = f.tell() + + if found_reply: + break + + # 2. Check chat-history.jsonl (from harvester if active) + if chat_hist_file.exists() and chat_hist_file.stat().st_size > hist_pos: + with open(chat_hist_file, "r") as f: + f.seek(hist_pos) + for line in f: + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + if d.get("agent") == recipient: + m = d.get("text") or d.get("msg") + if m and (not msg_id or msg_id not in m) and not m.startswith("[from:super]"): + found_reply = m + break + except Exception: + pass + hist_pos = f.tell() + + if found_reply: + break + + # 3. Periodically poll thread messages via dm.py read every ~2.5s + if idx % 12 == 0: + try: + read_cmd = ["python3", str(BIN_DIR / "dm.py"), "read", "--agent", recipient, "--target", target, "--n", "3"] + r_read = subprocess.run(read_cmd, capture_output=True, text=True, timeout=8) + if r_read.returncode == 0 and r_read.stdout: + for block in r_read.stdout.split("\n---\n"): + block_s = block.strip() + if block_s and (not msg_id or f"[id:{msg_id}]" not in block_s) and not block_s.startswith("[from:super]"): + found_reply = block_s + break + except Exception: + pass + + if found_reply: + break + + time.sleep(0.2) + + sys.stdout.write("\r" + " " * 85 + "\r") + sys.stdout.flush() + + if found_reply: + print(f"\n{c_bold(c_cyan('[' + recipient + ']:'))}") + for l in found_reply.splitlines(): + print(f" {l}") + print() + return found_reply + else: + print(c_yellow(f"\n (No response received from {recipient} within {timeout}s)\n")) + return None + + except KeyboardInterrupt: + sys.stdout.write("\r" + " " * 85 + "\r") + sys.stdout.flush() + print(c_dim("\n (Stopped waiting for response)\n")) + return None + +def get_available_targets(agent: str) -> list: + """Find known sidechats and targets for an agent.""" + targets = [] + # 1. From job-sidechats.json + if (NETVM_ROOT / "job-sidechats.json").exists(): + try: + with open(NETVM_ROOT / "job-sidechats.json") as f: + sc_data = json.load(f) + for k, v in sc_data.items(): + if isinstance(v, dict): + if v.get("agent") == agent: + targets.append({"name": k, "type": "sidechat", "desc": f"registered coordination ({k})"}) + elif agent in k: + targets.append({"name": k, "type": "sidechat", "desc": f"registered sidechat ({k})"}) + except Exception: + pass + + # 2. Add well-known agent sidechats + if agent == "646": + targets.append({"name": "646 tasks", "type": "sidechat", "desc": "primary task & check-in sidechat"}) + targets.append({"name": "646-opm-work", "type": "sidechat", "desc": "opm work sidechat"}) + elif agent == "opm": + targets.append({"name": "heartbeat", "type": "sidechat", "desc": "heartbeat & health sidechat"}) + elif agent == "pip": + targets.append({"name": "646-pip-coord", "type": "sidechat", "desc": "pip & 646 coordination sidechat"}) + + # Deduplicate by name + seen = set() + unique_targets = [] + for t in targets: + if t["name"] not in seen: + seen.add(t["name"]) + unique_targets.append(t) + + # Add Main Chat last with caution label + unique_targets.append({ + "name": "main", + "type": "main", + "desc": "Main Chat (CAUTION: avoid per CHAT_POLICY.md)" + }) + return unique_targets + +def _render_chat_history(agent: str, target: str, limit: int = 5): + """Fetch and display recent chat messages from thread.""" + try: + read_cmd = ["python3", str(BIN_DIR / "dm.py"), "read", "--agent", agent, "--target", target, "--n", str(limit)] + r = subprocess.run(read_cmd, capture_output=True, text=True, timeout=12) + if r.returncode == 0 and r.stdout: + blocks = [b.strip() for b in r.stdout.split("\n---\n") if b.strip()] + if blocks: + print(c_dim("--- Recent Messages ---")) + for b in blocks[-limit:]: + if b.startswith("[from:super]"): + print(f"{c_green('[super]:')}") + elif f"[from:{agent}]" in b or f"from {agent}" in b: + print(f"{c_cyan('[' + agent + ']:')}") + else: + print(f"{c_dim('[message]:')}") + for line in b.splitlines()[:5]: + print(f" {line}") + print(c_dim("-----------------------\n")) + except Exception: + pass + +def cmd_dm_chat(args): + agent = args.agent + target = getattr(args, "target", None) + timeout = getattr(args, "timeout", 60) or 60 + + print("\n" + c_bold(f"=== INTERACTIVE CONVERSATION SESSION: {c_cyan(agent.upper())} ===") + "\n") + + # Target selection if not provided + if not target: + available = get_available_targets(agent) + print(c_bold("Select chat target:") + c_dim(" (Prefer sidechats to keep Main Chat clean)\n")) + for idx, t in enumerate(available, 1): + tag = c_green("[Sidechat - RECOMMENDED]") if t["type"] == "sidechat" else c_yellow("[Main Chat - USE WITH CAUTION]") + print(f" [{idx}] {c_bold(t['name']):<18} {tag} {c_dim(t['desc'])}") + print() + + try: + choice = input(f"Choose target [1-{len(available)}] (default: 1): ").strip() + if not choice: + target = available[0]["name"] + else: + choice_idx = int(choice) - 1 + if 0 <= choice_idx < len(available): + target = available[choice_idx]["name"] + else: + target = available[0]["name"] + except (ValueError, KeyboardInterrupt): + target = available[0]["name"] + + print(f"\nEntering chat session with {c_bold(agent)} on {c_cyan(target)}...") + if target == "main": + print(c_yellow(" ⚠ WARNING: Main Chat targeted. Per CHAT_POLICY.md, keep interactions minimal.")) + print(c_dim(" Commands: /exit (or /quit), /refresh (reload history), /switch (change target), /wo <body>\n")) + + # Initial history render + _render_chat_history(agent, target, limit=5) + + # REPL loop + while True: + try: + prompt_str = f"{c_green('super')} ({c_cyan(target)}) > " + line = input(prompt_str).strip() + if not line: + continue + + # Handle slash commands + if line in ("/exit", "/quit", ":q"): + print(c_dim("\nExited chat session.\n")) + break + elif line in ("/refresh", "/history"): + _render_chat_history(agent, target, limit=10) + continue + elif line == "/switch": + available = get_available_targets(agent) + print(c_bold("\nSelect new target:")) + for idx, t in enumerate(available, 1): + print(f" [{idx}] {t['name']} ({t['desc']})") + try: + c = input(f"Choose target [1-{len(available)}]: ").strip() + if c and int(c) - 1 in range(len(available)): + target = available[int(c) - 1]["name"] + print(f"Switched target to {c_cyan(target)}.\n") + _render_chat_history(agent, target, limit=5) + except Exception: + pass + continue + elif line.startswith("/wo "): + parts = line[4:].split(" ", 1) + wo_title = parts[0] + wo_body = parts[1] if len(parts) > 1 else wo_title + wo_id = hashlib.sha256(f"super-{agent}-{wo_title}-{time.time()}".encode()).hexdigest()[:8] + wo_content = f"[WO:{wo_id}] {wo_title} — {wo_body}" + signed_wire, mid = sign_message("super", wo_content, msg_id=wo_id) + print(c_dim(f"Issuing Work Order [WO:{wo_id}]...")) + r = subprocess.run([ + "python3", str(BIN_DIR / "dm.py"), "send", + "--agent", "super", "--to", agent, "--target", target, + "--raw", signed_wire + ], capture_output=True, text=True) + if r.returncode == 0: + print(c_green(f"✔ Work Order [WO:{wo_id}] delivered.")) + wait_for_reply(agent, target, msg_id=wo_id, timeout=timeout) + else: + print(c_red(f"Delivery failed: {r.stderr or r.stdout}")) + continue + + # Standard message + signed_wire, mid = sign_message("super", line) + r = subprocess.run([ + "python3", str(BIN_DIR / "dm.py"), "send", + "--agent", "super", "--to", agent, "--target", target, + "--raw", signed_wire + ], capture_output=True, text=True) + + if r.returncode == 0: + print(c_green(f"✔ Delivered & verified [{mid}].")) + wait_for_reply(agent, target, msg_id=mid, timeout=timeout) + else: + print(c_red(f"Delivery failed: {r.stderr or r.stdout}")) + + except (KeyboardInterrupt, EOFError): + print(c_dim("\nExited chat session.\n")) + break + +def cmd_dm_send(args): + sender = resolve_sender(args) + recipient = args.to + target = getattr(args, "target", "main") + message = args.message + wait = getattr(args, "wait", False) + timeout = getattr(args, "timeout", 60) or 60 + + if target == "main": + print(c_yellow(" ⚠ [CHAT POLICY NOTE] Targeting Main Chat. Per CHAT_POLICY.md, avoid using Main Chat for routine tasks.")) + + should_sign = (sender == "super") and not getattr(args, "no_sign", False) + mid = None + if should_sign: + try: + signed_wire, mid = sign_message(sender, message) + cmd = [ + "python3", str(BIN_DIR / "dm.py"), "send", + "--agent", sender, + "--to", recipient, + "--target", target, + "--raw", + signed_wire + ] + print(f"Dispatching Cryptographically Signed DM [{c_green('verified from:' + sender)}] -> [{c_bold(recipient)}/{target}]...") + res = subprocess.run(cmd) + if res.returncode != 0: + sys.exit(res.returncode) + if wait: + wait_for_reply(recipient, target, msg_id=mid, timeout=timeout) + sys.exit(0) + except Exception as e: + print(f"Signing notice: {e}, sending standard DM...", file=sys.stderr) + + cmd = [ + "python3", str(BIN_DIR / "dm.py"), "send", + "--agent", sender, + "--to", recipient, + "--target", target, + ] + if getattr(args, "expect_reply", False): + cmd.append("--expect-reply") + if getattr(args, "reply_timeout", None): + cmd.extend(["--reply-timeout", str(args.reply_timeout)]) + cmd.append(message) + + print(f"Dispatching DM [{c_cyan('from:' + sender)}] -> [{c_bold(recipient)}/{target}]...") + res = subprocess.run(cmd) + if res.returncode != 0: + sys.exit(res.returncode) + if wait: + wait_for_reply(recipient, target, msg_id=mid, timeout=timeout) + sys.exit(0) + +def cmd_dm_wo(args): + sender = resolve_sender(args) + recipient = args.to + target = getattr(args, "target", "main") + title = args.title + body = args.body + priority = getattr(args, "priority", "routine") + + if target == "main": + print(c_yellow(" ⚠ [CHAT POLICY NOTE] Targeting Main Chat. Per CHAT_POLICY.md, prefer sidechats for work orders.")) + + wo_id = hashlib.sha256(f"{sender}-{recipient}-{title}-{time.time()}".encode()).hexdigest()[:8] + prefix = "[URGENT] " if priority == "urgent" else "" + wo_content = f"{prefix}[WO:{wo_id}] {title} — {body}" + + should_sign = (sender == "super") and not getattr(args, "no_sign", False) + if should_sign: + try: + signed_wire, mid = sign_message(sender, wo_content, msg_id=wo_id) + cmd = [ + "python3", str(BIN_DIR / "dm.py"), "send", + "--agent", sender, + "--to", recipient, + "--target", target, + "--raw", + signed_wire + ] + print(f"Issuing Cryptographically Signed Work Order {c_green('[WO:' + wo_id + ']')} [{c_green('verified from:' + sender)}] -> [{c_bold(recipient)}/{target}]...") + res = subprocess.run(cmd) + sys.exit(res.returncode) + except Exception as e: + print(f"Signing notice: {e}, sending standard work order...", file=sys.stderr) + + cmd = [ + "python3", str(BIN_DIR / "dm.py"), "send", + "--agent", sender, + "--to", recipient, + "--target", target, + "--expect-reply", + wo_content + ] + + print(f"Issuing Work Order {c_cyan('[WO:' + wo_id + ']')} [{c_cyan('from:' + sender)}] -> [{c_bold(recipient)}/{target}]...") + res = subprocess.run(cmd) + sys.exit(res.returncode) + +def cmd_dm_ack(args): + sender = resolve_sender(args) + recipient = args.to + target = getattr(args, "target", "main") + ref_id = args.id + + ack_message = f"[ACK:{ref_id}]" + cmd = [ + "python3", str(BIN_DIR / "dm.py"), "send", + "--agent", sender, + "--to", recipient, + "--target", target, + ack_message + ] + + print(f"Sending Acknowledgment {c_green('[ACK:' + ref_id + ']')} -> [{c_bold(recipient)}/{target}]...") + res = subprocess.run(cmd) + sys.exit(res.returncode) + +def cmd_dm_verify(args): + cmd = ["python3", str(BIN_DIR / "dm.py"), "verify-sig"] + if getattr(args, "agent", None): + cmd.extend(["--agent", args.agent]) + if getattr(args, "target", None): + cmd.extend(["--target", args.target]) + if getattr(args, "message", None): + cmd.append(args.message) + res = subprocess.run(cmd) + sys.exit(res.returncode) + +def cmd_dm_log(args): + n = getattr(args, "n", 25) + filter_agent = getattr(args, "agent", None) + filter_text = getattr(args, "filter", None) + + if not DM_LOG.exists(): + print(c_dim("dm-log.jsonl not found.")) + return + + entries = [] + with open(DM_LOG, "r") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + if filter_agent and (data.get("agent") != filter_agent and data.get("to") != filter_agent): + continue + if filter_text: + if filter_text.lower() not in json.dumps(data).lower(): + continue + entries.append(data) + except Exception: + continue + + entries = entries[-n:] + + if args.json: + print(json.dumps({"ok": True, "entries": entries}, indent=2)) + return + + print("\n" + c_bold(f"=== RECENT INTER-AGENT DMs (last {len(entries)}) ===") + "\n") + + headers = ["TIME", "TYPE", "FROM -> TO", "TARGET", "STATUS / DETAIL"] + rows = [] + for item in entries: + t_rel = parse_relative_time(item.get("ts", "")) + ev_type = item.get("type", "unknown") + + # Colorize type badges + if ev_type == "sent" or ev_type == "send_done": + badge = c_green("SENT") + elif ev_type == "verified": + badge = c_green("✔ VERIFIED") + elif "fail" in ev_type: + badge = c_red("FAIL") + elif ev_type == "retry": + badge = c_yellow("RETRY") + elif ev_type == "send_start": + badge = c_blue("START") + else: + badge = c_dim(ev_type[:8].upper()) + + from_to = f"{item.get('agent', '-')} -> {item.get('to', '-')}" + target = item.get("target", "-") + + msg = item.get("msg") or item.get("reason") or item.get("error") or "" + if not msg: + if ev_type == "retry": + msg = f"attempt {item.get('attempt', '?')} (id: {item.get('id', '-')})" + elif ev_type == "failed": + msg = f"delivery failed (id: {item.get('id', '-')}, verified: {item.get('verified', False)})" + elif ev_type == "sent": + msg = f"delivered (id: {item.get('id', '-')}, verified: {item.get('verified', True)})" + elif ev_type == "verified": + msg = f"confirmed in DOM (id: {item.get('id', '-')})" + elif ev_type == "send_done": + msg = f"completed send (id: {item.get('id', '-')})" + elif ev_type == "alias_resolved": + msg = f"resolved {item.get('target')} -> {item.get('thread_uuid', '')[:8]}" + elif ev_type == "nav_ok": + msg = f"navigated to thread {item.get('thread_uuid', '')[:8]}" + elif item.get("id"): + msg = f"id: {item.get('id')}" + msg_clean = " ".join(msg.split()) + + # Highlight protocol tokens + if "[WO:" in msg_clean: + msg_clean = re.sub(r"\[WO:([a-f0-9]+)\]", lambda m: c_magenta(m.group(0)), msg_clean) + if "[ACK:" in msg_clean: + msg_clean = re.sub(r"\[ACK:([a-f0-9]+)\]", lambda m: c_green(m.group(0)), msg_clean) + if "[JOB" in msg_clean: + msg_clean = re.sub(r"\[JOB ([^\]]+)\]", lambda m: c_cyan(m.group(0)), msg_clean) + if "[RESULT" in msg_clean: + msg_clean = re.sub(r"\[RESULT ([^\]]+)\]", lambda m: c_yellow(m.group(0)), msg_clean) + + rows.append([ + t_rel, + badge, + from_to, + target[:16], + msg_clean[:60] if msg_clean else c_dim("-") + ]) + + print_table(headers, rows) + print("\n" + c_dim(" Commands: super dm tail | super dm send --to <agent> \"msg\" | super dm wo --to <agent> --title \"T\" \"body\"") + "\n") + +def cmd_dm_tail(args): + filter_text = getattr(args, "filter", None) + print(c_bold("=== STREAMING DM LOG (Ctrl+C to stop) ===") + "\n") + + if not DM_LOG.exists(): + print(c_red("Error: dm-log.jsonl does not exist.")) + return + + with open(DM_LOG, "r") as f: + # Seek to end + f.seek(0, os.SEEK_END) + try: + while True: + line = f.readline() + if not line: + time.sleep(0.5) + continue + try: + data = json.loads(line.strip()) + if filter_text and filter_text.lower() not in line.lower(): + continue + ts = parse_relative_time(data.get("ts", "")) + ev_type = data.get("type", "") + sender = data.get("agent", "") + to = data.get("to", "") + msg = data.get("msg") or data.get("reason") or data.get("error") or "" + print(f"[{ts}] {c_cyan(ev_type.upper()):<12} {sender} -> {to}: {msg[:90]}") + except Exception: + pass + except KeyboardInterrupt: + print("\n" + c_dim("Tail stopped.")) + +def cmd_dm_send_file(args): + file_path = Path(args.file).resolve() + if not file_path.exists() or not file_path.is_file(): + print(c_red(f"Error: File not found: {file_path}"), file=sys.stderr) + sys.exit(1) + + recipient = args.to + target = getattr(args, "target", None) + if not target: + target = DEFAULT_AGENT_SIDECHATS.get(recipient, "main") + + if target == "main": + print(c_yellow(" ⚠ [CHAT POLICY NOTE] Targeting Main Chat. Per CHAT_POLICY.md, avoid using Main Chat for routine payloads.")) + + note = getattr(args, "note", None) + wait = getattr(args, "wait", False) + timeout = getattr(args, "timeout", 60) or 60 + + # 1. Compute checksum and file stats + file_stat = file_path.stat() + size_bytes = file_stat.st_size + size_kb = size_bytes / 1024.0 + + hasher = hashlib.sha256() + with open(file_path, "rb") as f: + while chunk := f.read(65536): + hasher.update(chunk) + sha256_full = hasher.hexdigest() + sha256_short = sha256_full[:12] + + # 2. Stage file in transfers directory + agent_transfers_dir = TRANSFERS_DIR / recipient + agent_transfers_dir.mkdir(parents=True, exist_ok=True) + + ts_prefix = datetime.now().strftime("%Y%m%d_%H%M%S") + dest_filename = f"{ts_prefix}_{file_path.name}" + dest_path = agent_transfers_dir / dest_filename + shutil.copy2(file_path, dest_path) + + # 3. Construct protocol payload pointer + file_id = hashlib.sha256(f"{recipient}-{dest_filename}-{time.time()}".encode()).hexdigest()[:8] + + lines = [ + f"[FILE:{file_id}] {file_path.name} ({size_kb:.1f} KB, sha256:{sha256_short})", + f"Path: {dest_path}", + ] + if note: + lines.append(f"Note: {note}") + + payload_message = "\n".join(lines) + + # 4. Dispatch cryptographically signed DM to recipient sidechat + signed_wire, mid = sign_message("super", payload_message, msg_id=file_id) + print(f"Staged file {c_bold(file_path.name)} -> {c_dim(str(dest_path))}") + print(f"Dispatching File Pointer {c_green('[FILE:' + file_id + ']')} [{c_green('verified from:super')}] -> [{c_bold(recipient)}/{c_cyan(target)}]...") + + cmd = [ + "python3", str(BIN_DIR / "dm.py"), "send", + "--agent", "super", + "--to", recipient, + "--target", target, + "--raw", + signed_wire + ] + res = subprocess.run(cmd) + if res.returncode != 0: + print(c_red(f"File notification delivery failed with code {res.returncode}"), file=sys.stderr) + sys.exit(res.returncode) + + print(c_green(f"✔ File notification delivered to {recipient}/{target}.")) + + if wait: + wait_for_reply(recipient, target, msg_id=file_id, timeout=timeout) + +def cmd_dm_files(args): + filter_agent = getattr(args, "agent", None) + + if not TRANSFERS_DIR.exists(): + print(c_dim("No transferred files found (transfers/ directory is empty).")) + return + + records = [] + agent_dirs = [TRANSFERS_DIR / filter_agent] if filter_agent else sorted(TRANSFERS_DIR.iterdir()) + + for ad in agent_dirs: + if not ad.is_dir(): + continue + agent_name = ad.name + for fp in sorted(ad.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): + if not fp.is_file(): + continue + st = fp.stat() + size_kb = f"{st.st_size / 1024:.1f} KB" + mtime = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat() + t_rel = parse_relative_time(mtime) + + clean_name = fp.name + m = re.match(r"^\d{8}_\d{6}_(.+)$", fp.name) + if m: + clean_name = m.group(1) + + records.append({ + "agent": agent_name, + "filename": clean_name, + "staged_name": fp.name, + "path": str(fp), + "size_kb": size_kb, + "mtime": mtime, + "time_rel": t_rel + }) + + if args.json: + print(json.dumps({"ok": True, "files": records}, indent=2)) + return + + print("\n" + c_bold(f"=== TRANSFERRED PAYLOADS & STAGED FILES ({len(records)}) ===") + "\n") + headers = ["AGENT", "FILENAME", "SIZE", "TRANSFERRED", "STAGED PATH"] + rows = [] + for r in records: + rows.append([ + c_cyan(r["agent"]), + c_bold(r["filename"]), + r["size_kb"], + r["time_rel"], + c_dim(r["path"]) + ]) + print_table(headers, rows) + print("\n" + c_dim(" Commands: super dm send-file --to <agent> <path> | super dm files --agent <agent>") + "\n") + +# --------------------------------------------------------------------------- +# Domain: THREAD (Chat Oversight via box-chat.py) +# --------------------------------------------------------------------------- +def cmd_thread_list(args): + agent = args.agent + cmd = ["python3", str(BIN_DIR / "box-chat.py"), "thread-list", agent] + res = subprocess.run(cmd, capture_output=True, text=True) + + try: + data = json.loads(res.stdout) + except Exception: + print(c_red("Failed to parse box-chat.py response:") + f"\n{res.stdout}\n{res.stderr}") + return + + if args.json: + print(json.dumps(data, indent=2)) + return + + if not data.get("ok"): + print(c_red(f"Error fetching threads for {agent}: {data.get('error', 'unknown error')}")) + return + + threads = data.get("threads", []) + print("\n" + c_bold(f"=== ACTIVE THREADS FOR {agent.upper()} ({len(threads)}) ===") + "\n") + + headers = ["ID / UUID", "KIND", "TITLE", "PARTICIPANTS", "LAST ACTIVE"] + rows = [] + for t in threads: + tid = t.get("id", "") + tid_disp = c_cyan(tid[:12]) if tid != "main" else c_bold("main") + kind = t.get("kind", "") + title = t.get("title", "") + parts = ", ".join(t.get("participants", [])) + last_act = parse_relative_time(t.get("last_message_at", "")) + + rows.append([tid_disp, kind, title[:35], parts, last_act]) + + print_table(headers, rows) + print("\n" + c_dim(f" View thread: super thread view {agent} <id>") + "\n") + +def cmd_thread_view(args): + agent = args.agent + thread_id = args.thread_id + limit = getattr(args, "limit", 15) + + cmd = ["python3", str(BIN_DIR / "box-chat.py"), "thread-messages", agent, thread_id, "--limit", str(limit)] + res = subprocess.run(cmd, capture_output=True, text=True) + + try: + data = json.loads(res.stdout) + except Exception: + print(c_red("Failed to parse box-chat.py response:") + f"\n{res.stdout}\n{res.stderr}") + return + + if args.json: + print(json.dumps(data, indent=2)) + return + + if not data.get("ok"): + print(c_red(f"Error viewing thread {thread_id}: {data.get('error', 'unknown error')}")) + return + + messages = data.get("messages", []) + print("\n" + c_bold(f"=== THREAD {thread_id} ({agent}) — {len(messages)} messages ===") + "\n") + + for msg in messages: + sender_info = msg.get("from", {}) + sender_name = sender_info.get("name") or sender_info.get("role") or "unknown" + text = msg.get("text", "") + + # Colorize sender + if sender_name in ("human", "super", "operator-main"): + sender_tag = c_yellow(f"[{sender_name}]") + else: + sender_tag = c_cyan(f"[{sender_name}]") + + print(f"{sender_tag}:") + for line in text.split("\n"): + print(f" {line}") + print() + +# --------------------------------------------------------------------------- +# Domain: JOB (Systemd Timers & Job Definitions) +# --------------------------------------------------------------------------- +def cmd_job_list(args): + cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-list"] + res = subprocess.run(cmd, capture_output=True, text=True) + + try: + data = json.loads(res.stdout) + except Exception: + print(c_red("Failed to load jobs:") + f"\n{res.stdout}") + return + + if args.json: + print(json.dumps(data, indent=2)) + return + + jobs = data.get("jobs", []) + print("\n" + c_bold(f"=== DEFINED JOBS ({len(jobs)}) ===") + "\n") + + headers = ["NAME", "AGENT", "SCHEDULE", "TIMEOUT", "DESCRIPTION"] + rows = [] + for j in jobs: + sched = j.get("schedule", "-") + sched_disp = c_green(sched) if sched != "manual" else c_dim("manual") + rows.append([ + c_bold(j.get("name", "")), + j.get("agent", "-"), + sched_disp, + f"{j.get('timeout', '-')}s", + (j.get("description") or "-")[:50] + ]) + + print_table(headers, rows) + print("\n" + c_dim(" Commands: super job show <name> | super job status <name> | super job run <name> | super job create <name>") + "\n") + +def cmd_job_show(args): + name = args.name + # 1. Fetch job definition via box-ctl + cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-get", name] + res = subprocess.run(cmd, capture_output=True, text=True) + try: + jdata = json.loads(res.stdout) + except Exception: + print(c_red(f"Failed to query job '{name}': {res.stdout or res.stderr}")) + return + + if not jdata.get("ok"): + print(c_red(f"Error: {jdata.get('error', 'Job not found')}")) + return + + job = jdata.get("job", {}) + + # 2. Fetch timer status + tcmd = ["python3", str(BIN_DIR / "box-ctl.py"), "timer-status", name] + tres = subprocess.run(tcmd, capture_output=True, text=True) + tdata = None + try: + parsed_t = json.loads(tres.stdout) + if parsed_t.get("ok"): + tdata = parsed_t + except Exception: + pass + + if args.json: + print(json.dumps({"ok": True, "job": job, "timer": tdata}, indent=2)) + return + + print("\n" + c_bold(f"=== JOB DEFINITION: {name} ===") + "\n") + print(f" Description : {job.get('description', '-')}") + print(f" Target Agent: {c_cyan(job.get('agent', '-'))}") + sched = job.get('schedule', '-') + print(f" Schedule : {c_green(sched) if sched != 'manual' else c_dim('manual')}") + print(f" Timeout : {job.get('timeout', 300)}s") + print(f" On Failure : {job.get('on_failure', 'alert')}") + if job.get("chain_next"): + print(f" Chain Next : {job.get('chain_next')}") + if job.get("dm_target"): + print(f" DM Target : {job.get('dm_target')}") + + sidechat = job.get("sidechat") + if sidechat and any(sidechat.values()): + print(f"\n {c_bold('Sidechat Config:')}") + print(f" Create : {sidechat.get('create', False)}") + if sidechat.get("name_template"): + print(f" Name Template : {sidechat.get('name_template')}") + if sidechat.get("reuse_key"): + print(f" Reuse Key : {sidechat.get('reuse_key')}") + + followup = job.get("followup") + if followup: + print(f"\n {c_bold('Follow-up Policy:')}") + print(f" Expect Reply : {followup.get('expect_reply', False)}") + print(f" Timeout : {followup.get('timeout', '-')}") + print(f" Nudges : {followup.get('nudges', 0)}") + if followup.get("escalate"): + print(f" Escalate To : {followup.get('escalate')}") + if followup.get("route"): + print(f" Route : {followup.get('route')}") + + print(f"\n {c_bold('Prompt Template:')}") + prompt = job.get("prompt_template", "") + for line in prompt.splitlines(): + print(f" {c_dim(line)}") + + print(f"\n {c_bold('Systemd Timer Status:')}") + if tdata: + unit = tdata.get("unit", f"job-{name}.timer") + act = badge_ok() if tdata.get("active") else badge_err("INACTIVE") + en = str(tdata.get("enabled")) + cal = tdata.get("oncalendar") or "-" + last_run = tdata.get("last_run") or "-" + next_run = tdata.get("next_run") or "-" + res_str = tdata.get("last_result") or "-" + print(f" Unit File : {unit}") + print(f" Active : {act}") + print(f" Enabled : {en}") + print(f" Schedule : {c_cyan(cal)}") + print(f" Last Run : {last_run}") + print(f" Next Run : {next_run}") + print(f" Result : {res_str}") + else: + print(f" {badge_dim('NOT CONFIGURED')} (Run 'super job enable {name}' to activate)") + print() + +def cmd_job_status(args): + name = getattr(args, "name", None) + if name: + cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "timer-status", name] + else: + cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "timer-list"] + + res = subprocess.run(cmd, capture_output=True, text=True) + try: + data = json.loads(res.stdout) + except Exception: + print(c_red("Failed to query timers:") + f"\n{res.stdout}") + return + + if args.json: + print(json.dumps(data, indent=2)) + return + + if name: + if not data.get("ok"): + print(c_red(f"Error querying timer '{name}': {data.get('error', 'Unknown error')}")) + return + print(f"\n{c_bold('Timer Status: ' + name)}") + print(f" Unit File : {data.get('unit')}") + print(f" Active : {badge_ok() if data.get('active') else badge_err('INACTIVE')}") + print(f" Enabled : {data.get('enabled')}") + print(f" Schedule : {c_cyan(data.get('oncalendar', '-'))}") + print(f" Last Run : {data.get('last_run') or '-'}") + print(f" Next Run : {data.get('next_run') or '-'}") + print(f" Result : {data.get('last_result') or '-'}\n") + else: + timers = data.get("timers", []) + print("\n" + c_bold(f"=== ACTIVE TIMERS ({len(timers)}) ===") + "\n") + headers = ["NAME", "UNIT", "ACTIVE", "ENABLED"] + rows = [] + for t in timers: + act = badge_ok() if t.get("active") else badge_err("INACTIVE") + rows.append([c_bold(t.get("name", "")), t.get("unit", ""), act, str(t.get("enabled"))]) + print_table(headers, rows) + print() + +def cmd_job_create(args): + name = args.name + if not re.match(r"^[a-z0-9-]{1,64}$", name): + print(c_red("Error: Job name must match ^[a-z0-9-]{1,64}$"), file=sys.stderr) + sys.exit(1) + + job_file = JOBS_DIR / f"{name}.json" + existed = job_file.exists() + update_mode = getattr(args, "update", False) + if existed and not update_mode: + print(c_red(f"Error: Job '{name}' already exists. Use --update to modify existing job."), file=sys.stderr) + sys.exit(1) + + file_arg = getattr(args, "file", None) + if file_arg: + try: + if file_arg == "-": + raw_data = json.load(sys.stdin) + else: + with open(file_arg, "r") as f: + raw_data = json.load(f) + job_dict = raw_data + job_dict["name"] = name + except Exception as e: + print(c_red(f"Error loading JSON from '{file_arg}': {e}"), file=sys.stderr) + sys.exit(1) + else: + # Check required fields + if not args.agent: + print(c_red("Error: --agent is required when not using --file."), file=sys.stderr) + sys.exit(1) + if not args.prompt: + print(c_red("Error: --prompt is required when not using --file."), file=sys.stderr) + sys.exit(1) + + job_dict = { + "name": name, + "agent": args.agent, + "schedule": args.schedule or "manual", + "description": args.description or f"Job {name}", + "prompt_template": args.prompt, + "timeout": args.timeout or 300, + "on_failure": getattr(args, "on_failure", "alert") or "alert", + "chain_next": getattr(args, "chain_next", None), + } + if getattr(args, "sidechat_create", False) or getattr(args, "sidechat_name", None) or getattr(args, "reuse_key", None): + job_dict["sidechat"] = { + "create": bool(getattr(args, "sidechat_create", False) or getattr(args, "sidechat_name", None) or getattr(args, "reuse_key", None)), + } + if getattr(args, "sidechat_name", None): + job_dict["sidechat"]["name_template"] = args.sidechat_name + if getattr(args, "reuse_key", None): + job_dict["sidechat"]["reuse_key"] = args.reuse_key + + if getattr(args, "dm_target", None): + job_dict["dm_target"] = args.dm_target + + # Call box-ctl.py job-put + cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-put", name] + res = subprocess.run(cmd, input=json.dumps(job_dict), capture_output=True, text=True) + try: + resp = json.loads(res.stdout) + except Exception: + print(c_red(f"Failed to save job via box-ctl: {res.stdout or res.stderr}"), file=sys.stderr) + sys.exit(1) + + if not resp.get("ok"): + print(c_red(f"Error saving job: {resp.get('error', 'unknown error')}"), file=sys.stderr) + if "detail" in resp: + print(c_dim(f"Detail: {json.dumps(resp['detail'])}"), file=sys.stderr) + sys.exit(1) + + action_label = "Updated" if existed else "Created" + print(c_green(f"✔ {action_label} job '{name}' (saved to jobs/{name}.json and committed to git)")) + + # Manage timer + schedule = job_dict.get("schedule", "manual") + no_enable = getattr(args, "no_enable", False) + + timer_unit = Path.home() / ".config" / "systemd" / "user" / f"job-{name}.timer" + timer_existed = timer_unit.exists() + + if schedule != "manual" and not no_enable: + if timer_existed: + subprocess.run(["python3", str(BIN_DIR / "box-ctl.py"), "timer-delete", name, "--keep-job"], + capture_output=True, text=True) + r_timer = subprocess.run(["python3", str(BIN_DIR / "box-ctl.py"), "timer-create", name], + capture_output=True, text=True) + try: + tresp = json.loads(r_timer.stdout) + if tresp.get("ok"): + print(c_green(f"✔ Systemd timer active: job-{name}.timer ({tresp.get('oncalendar')})")) + else: + print(c_yellow(f"⚠ Timer could not be activated: {tresp.get('error')}")) + except Exception: + print(c_yellow(f"⚠ Timer creation output: {r_timer.stdout or r_timer.stderr}")) + elif timer_existed and schedule == "manual": + subprocess.run(["python3", str(BIN_DIR / "box-ctl.py"), "timer-delete", name, "--keep-job"], + capture_output=True, text=True) + print(c_dim("Systemd timer removed because schedule is set to 'manual'.")) + +def cmd_job_enable(args): + name = args.name + job_file = JOBS_DIR / f"{name}.json" + if not job_file.exists(): + print(c_red(f"Error: Job definition jobs/{name}.json not found."), file=sys.stderr) + sys.exit(1) + + try: + jdata = json.loads(job_file.read_text()) + if jdata.get("schedule") == "manual": + print(c_red(f"Error: Cannot enable timer for job '{name}' with schedule 'manual'."), file=sys.stderr) + print(c_dim("Update schedule to a valid cron pattern first using 'super job create --update'."), file=sys.stderr) + sys.exit(1) + except Exception as e: + print(c_red(f"Error reading job definition: {e}"), file=sys.stderr) + sys.exit(1) + + timer_unit = Path.home() / ".config" / "systemd" / "user" / f"job-{name}.timer" + if not timer_unit.exists(): + print(f"Timer unit not found for '{name}'. Creating systemd timer...") + res = subprocess.run(["python3", str(BIN_DIR / "box-ctl.py"), "timer-create", name], + capture_output=True, text=True) + else: + print(f"Activating existing timer for '{name}'...") + res = subprocess.run(["python3", str(BIN_DIR / "box-ctl.py"), "timer-enable", name], + capture_output=True, text=True) + + try: + data = json.loads(res.stdout) + if data.get("ok"): + print(c_green(f"✔ Timer job-{name}.timer enabled and active.")) + else: + print(c_red(f"Error enabling timer: {data.get('error', 'unknown error')}")) + sys.exit(1) + except Exception: + print(res.stdout or res.stderr) + +def cmd_job_disable(args): + name = args.name + timer_unit = Path.home() / ".config" / "systemd" / "user" / f"job-{name}.timer" + if not timer_unit.exists(): + print(c_yellow(f"Timer job-{name}.timer is not currently installed.")) + return + + print(f"Disabling timer for '{name}'...") + res = subprocess.run(["python3", str(BIN_DIR / "box-ctl.py"), "timer-disable", name], + capture_output=True, text=True) + subprocess.run(["python3", str(BIN_DIR / "box-ctl.py"), "timer-stop", name], + capture_output=True, text=True) + try: + data = json.loads(res.stdout) + if data.get("ok"): + print(c_green(f"✔ Timer job-{name}.timer disabled and stopped.")) + else: + print(c_red(f"Error disabling timer: {data.get('error', 'unknown error')}")) + sys.exit(1) + except Exception: + print(res.stdout or res.stderr) + +def cmd_job_delete(args): + name = args.name + job_file = JOBS_DIR / f"{name}.json" + timer_unit = Path.home() / ".config" / "systemd" / "user" / f"job-{name}.timer" + + if not job_file.exists() and not timer_unit.exists(): + print(c_red(f"Error: Job '{name}' not found."), file=sys.stderr) + sys.exit(1) + + force = getattr(args, "force", False) + yes = getattr(args, "yes", False) + + if not (yes or force): + prompt_msg = f"Are you sure you want to delete job '{c_bold(name)}' and its timer? [y/N]: " + try: + choice = input(prompt_msg).strip().lower() + if choice not in ("y", "yes"): + print(c_dim("Deletion aborted.")) + return + except KeyboardInterrupt: + print("\n" + c_dim("Deletion aborted.")) + return + + # 1. Delete timer if exists + if timer_unit.exists(): + print(f"Removing systemd timer job-{name}.timer...") + r_timer = subprocess.run(["python3", str(BIN_DIR / "box-ctl.py"), "timer-delete", name, "--keep-job"], + capture_output=True, text=True) + try: + tdata = json.loads(r_timer.stdout) + if not tdata.get("ok") and not force: + print(c_red(f"Error deleting timer: {tdata.get('error')}"), file=sys.stderr) + sys.exit(1) + except Exception: + pass + + # 2. Delete job file + if job_file.exists(): + print(f"Removing job definition jobs/{name}.json...") + r_job = subprocess.run(["python3", str(BIN_DIR / "box-ctl.py"), "job-delete", name, "--force"], + capture_output=True, text=True) + try: + jdata = json.loads(r_job.stdout) + if jdata.get("ok"): + print(c_green(f"✔ Job '{name}' and its systemd units deleted successfully.")) + else: + print(c_red(f"Error deleting job: {jdata.get('error')}"), file=sys.stderr) + sys.exit(1) + except Exception: + print(r_job.stdout or r_job.stderr) + +def cmd_job_run(args): + name = args.name + follow = getattr(args, "follow", False) + + job_file = JOBS_DIR / f"{name}.json" + if not job_file.exists(): + print(c_red(f"Error: Job definition jobs/{name}.json not found."), file=sys.stderr) + sys.exit(1) + + print(f"Triggering job dispatch for '{c_bold(name)}'...") + + if not follow: + cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-trigger", name] + res = subprocess.run(cmd) + sys.exit(res.returncode) + + # Follow mode: open log files and track from current end position + dm_pos = DM_LOG.stat().st_size if DM_LOG.exists() else 0 + job_pos = JOB_LOG.stat().st_size if JOB_LOG.exists() else 0 + + # Trigger job in background + cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-trigger", name] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + print(c_dim(f"Following execution logs for '{name}' (Press Ctrl+C to exit)...")) + + start_time = time.time() + dispatched = False + completed = False + + try: + while time.time() - start_time < 90: # 90s max follow + if proc.poll() is not None and not dispatched: + out_txt, err_txt = proc.communicate() + try: + pdata = json.loads(out_txt) + if not pdata.get("ok"): + print(c_red(f"Trigger failed: {pdata.get('error')}")) + return + else: + dispatched = True + except Exception: + pass + + if JOB_LOG.exists() and JOB_LOG.stat().st_size > job_pos: + with open(JOB_LOG, "r") as f: + f.seek(job_pos) + for line in f: + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + if d.get("job_name") == name or name in str(d.get("job_id", "")): + ev = d.get("type") or d.get("event") or "event" + badge = c_green("DISPATCH") if "dispatched" in ev else ( + c_red("FAILED") if "fail" in ev else c_cyan(ev.upper()) + ) + print(f" {badge} [{c_bold(name)}] {d.get('target', '')} {d.get('error', '')}") + if "failed" in ev or "timeout" in ev: + completed = True + except Exception: + pass + job_pos = f.tell() + + if DM_LOG.exists() and DM_LOG.stat().st_size > dm_pos: + with open(DM_LOG, "r") as f: + f.seek(dm_pos) + for line in f: + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + msg = d.get("msg") or d.get("error") or "" + if name in msg or "[RESULT" in msg or "[WO" in msg: + ev_type = d.get("type", "") + sender = d.get("agent", "") + to = d.get("to", "") + badge = c_green("VERIFIED") if ev_type == "verified" else c_cyan(ev_type.upper()) + print(f" {badge} DM {sender} -> {to}: {msg[:100]}") + if "[RESULT" in msg: + completed = True + except Exception: + pass + dm_pos = f.tell() + + if completed: + print(c_green(f"✔ Job execution completed.")) + break + + time.sleep(0.5) + + except KeyboardInterrupt: + print("\n" + c_dim("Stopped following.")) + +def cmd_job_log(args): + n = getattr(args, "n", 20) + filter_name = getattr(args, "name", None) + + if not JOB_LOG.exists(): + print(c_dim("job-log.jsonl not found.")) + return + + entries = [] + with open(JOB_LOG, "r") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + if filter_name and d.get("job_name") != filter_name and filter_name not in d.get("job_id", ""): + continue + entries.append(d) + except Exception: + continue + + entries = entries[-n:] + if args.json: + print(json.dumps({"ok": True, "entries": entries}, indent=2)) + return + + print("\n" + c_bold(f"=== RECENT JOB ACTIVITY (last {len(entries)}) ===") + "\n") + headers = ["TIME", "EVENT", "JOB ID", "AGENT", "DETAIL"] + rows = [] + for item in entries: + t_rel = parse_relative_time(item.get("ts", "")) + ev = item.get("event", "-") + badge = c_green("DISPATCH") if "dispatched" in ev else c_dim(ev[:12]) + job_id = item.get("job_id", "-") + agent = item.get("agent", "-") + detail = item.get("target") or item.get("error") or "" + + rows.append([t_rel, badge, job_id[:25], agent, detail]) + + print_table(headers, rows) + print() + +# --------------------------------------------------------------------------- +# Domain: WEB (Google Cloud VM Web Surfaces Probe) +# --------------------------------------------------------------------------- +def cmd_web_health(args): + endpoints = [ + {"name": "Box Console (Front-Door)", "url": "https://box.muse-dev.online/"}, + {"name": "Board Service", "url": "https://board.muse-dev.online/"}, + {"name": "VM Reverse Proxy (sslip)", "url": "https://34-139-37-135.sslip.io/"}, + {"name": "Box Timers API", "url": "https://box.muse-dev.online/api/box/timers"}, + ] + + print("\n" + c_bold("=== GOOGLE CLOUD VM WEB SURFACES HEALTH (34.139.37.135) ===") + "\n") + + results = [] + headers = ["SURFACE", "ENDPOINT", "STATUS", "LATENCY", "SERVER / VIA"] + rows = [] + + for ep in endpoints: + t0 = time.time() + try: + req = urllib.request.Request( + ep["url"], + headers={"User-Agent": "super-cli/1.0 (bl; health-probe)"} + ) + with urllib.request.urlopen(req, timeout=4) as resp: + elapsed_ms = int((time.time() - t0) * 1000) + code = resp.status + via = resp.headers.get("Via") or resp.headers.get("Server") or "ok" + badge = badge_ok(f"{code}") + except urllib.error.HTTPError as e: + elapsed_ms = int((time.time() - t0) * 1000) + code = e.code + via = e.headers.get("Via") or e.headers.get("Server") or "-" + # 401/403 means auth works as intended + if code in (401, 403): + badge = badge_ok(f"{code} AUTH_GATED") + else: + badge = badge_warn(f"{code}") + except Exception as e: + elapsed_ms = None + code = "ERR" + via = str(e)[:25] + badge = badge_err("UNREACHABLE") + + lat_disp = f"{elapsed_ms}ms" if elapsed_ms is not None else "-" + rows.append([c_bold(ep["name"]), ep["url"], badge, lat_disp, via[:25]]) + results.append({"name": ep["name"], "url": ep["url"], "code": code, "latency_ms": elapsed_ms}) + + print_table(headers, rows) + print("\n" + c_dim(" Operator note: box.muse-dev.online requires PIN or Bearer session cookie.") + "\n") + + if args.json: + print(json.dumps({"ok": True, "results": results}, indent=2)) + +def cmd_web_test_auth(args): + print("\n" + c_bold("Testing Box API Authentication Gate:") + "\n") + url = "https://box.muse-dev.online/api/box/timers" + try: + req = urllib.request.Request(url, headers={"User-Agent": "super-cli/1.0"}) + with urllib.request.urlopen(req, timeout=4) as resp: + print(c_yellow(f"Unexpected: Endpoint responded without auth: HTTP {resp.status}")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="ignore") + if e.code in (401, 403): + print(f" Endpoint: {url}") + print(f" Response: {badge_ok(f'HTTP {e.code}')} (Protection active)") + print(f" Body : {c_dim(body.strip()[:100])}\n") + else: + print(c_warn(f"HTTP {e.code}: {body}")) + except Exception as e: + print(c_red(f"Error testing auth: {e}")) + +def cmd_web_sync(args): + print("\n" + c_bold("=== DATA SYNC INTEGRITY (bl -> VM) ===") + "\n") + files = [ + ("DM Log", DM_LOG), + ("Job Log", JOB_LOG), + ("Control Audit", CTL_LOG), + ] + headers = ["DATA STORE", "PATH", "SIZE", "RECORDS", "LAST MODIFIED"] + rows = [] + for label, path in files: + if path.exists(): + stat = path.stat() + size_kb = f"{stat.st_size / 1024:.1f} KB" + # Count lines + try: + with open(path, "r") as f: + lines = sum(1 for _ in f) + except Exception: + lines = 0 + mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() + mtime_rel = parse_relative_time(mtime) + rows.append([c_bold(label), str(path), size_kb, str(lines), mtime_rel]) + else: + rows.append([c_bold(label), str(path), "-", "0", c_dim("missing")]) + + print_table(headers, rows) + print() + +# --------------------------------------------------------------------------- +# Domain: HARVEST (Response & Readback Harvester) +# --------------------------------------------------------------------------- +def cmd_harvest_run(args): + cmd = [sys.executable, str(RESPONSE_HARVESTER_PY), "--once"] + if getattr(args, "agent", None): + cmd.extend(["--agent", args.agent]) + if getattr(args, "dry_run", False): + cmd.append("--dry-run") + if getattr(args, "json", False): + cmd.append("--json") + try: + res = subprocess.run(cmd, capture_output=True, text=True) + if getattr(args, "json", False): + print(res.stdout.strip()) + else: + if res.stdout.strip(): + print(res.stdout.strip()) + if res.stderr.strip(): + print(c_yellow(res.stderr.strip()), file=sys.stderr) + except Exception as e: + print(c_red(f"Error running harvest: {e}"), file=sys.stderr) + sys.exit(1) + +def cmd_harvest_status(args): + watermarks = {} + if WATERMARKS_FILE.exists(): + try: + with open(WATERMARKS_FILE, "r") as f: + watermarks = json.load(f) + except Exception: + pass + + sidechats = {} + if JOB_SIDECHATS_FILE.exists(): + try: + with open(JOB_SIDECHATS_FILE, "r") as f: + sidechats = json.load(f) + except Exception: + pass + + latest_seen = {} + if CHAT_HISTORY_LOG.exists(): + try: + with open(CHAT_HISTORY_LOG, "r") as f: + for line in f: + if not line.strip(): continue + try: + rec = json.loads(line) + k = f"{rec.get('agent')}:{rec.get('thread_id')}" + latest_seen[k] = rec.get("ts") + except Exception: + continue + except Exception: + pass + + if getattr(args, "json", False): + print(json.dumps({ + "watermarks": watermarks, + "sidechats": sidechats, + "latest_events": latest_seen + }, indent=2)) + return + + now_str = datetime.now().strftime("%H:%M:%S") + print(f"\n=== RESPONSE HARVESTER STATUS === ({now_str} local)\n") + + headers = ["AGENT", "THREAD / ALIAS", "ID", "WATERMARK", "LAST HARVESTED", "STATUS"] + rows = [] + + for agent in VALID_NODES: + wm_main = watermarks.get(f"{agent}:main", "-") + last_ts = latest_seen.get(f"{agent}:main") + status_b = badge_ok("ACTIVE") if wm_main != "-" else badge_dim("IDLE") + rows.append([ + c_bold(agent), + "Main Chat", + c_dim("main"), + wm_main[:16] if wm_main != "-" else c_dim("-"), + parse_relative_time(last_ts) if last_ts else c_dim("none"), + status_b + ]) + + for key, val in sidechats.items(): + if key.startswith("_"): continue + if isinstance(val, dict): + tid = val.get("thread_uuid") or val.get("uuid") + a = val.get("agent", "opm") + elif isinstance(val, str): + tid = val + a = "opm" + else: + continue + + if a == agent and tid: + wm_sc = watermarks.get(f"{agent}:{tid}", "-") + last_sc_ts = latest_seen.get(f"{agent}:{tid}") + status_sc = badge_ok("ACTIVE") if wm_sc != "-" else badge_dim("IDLE") + rows.append([ + c_dim(f" └─ {agent}"), + key, + c_dim(tid[:8] + "…"), + wm_sc[:16] if wm_sc != "-" else c_dim("-"), + parse_relative_time(last_sc_ts) if last_sc_ts else c_dim("none"), + status_sc + ]) + + print_table(headers, rows) + print(c_dim("\n Commands: super harvest run | super harvest tail\n")) + +def _print_harvest_line(line: str, filter_text: str = None): + try: + e = json.loads(line) + text = e.get("text", "") + if filter_text and filter_text.lower() not in text.lower(): + return + ts = e.get("ts", "")[11:19] + agent = e.get("agent", "unknown") + author = e.get("author", "unknown") + thread = e.get("thread_name") or e.get("thread_id", "main") + + auth_color = c_green if author == "assistant" else c_cyan + snippet = text.replace("\n", " ")[:120] + print(f" {c_dim(ts)} {c_bold(f'[{agent}:{thread}]')} {auth_color(author)}: {snippet}") + except Exception: + pass + +def cmd_harvest_tail(args): + if not CHAT_HISTORY_LOG.exists(): + print(c_dim(f"Chat history log empty ({CHAT_HISTORY_LOG})")) + return + print(c_bold(f"=== TAILING CHAT HARVEST STREAM ({CHAT_HISTORY_LOG.name}) === (Ctrl+C to stop)") + "\n") + try: + with open(CHAT_HISTORY_LOG, "r") as f: + lines = f.readlines() + for line in lines[-15:]: + _print_harvest_line(line, getattr(args, "filter", None)) + + f.seek(0, os.SEEK_END) + while True: + line = f.readline() + if line: + _print_harvest_line(line, getattr(args, "filter", None)) + else: + time.sleep(0.5) + except KeyboardInterrupt: + print() + +# --------------------------------------------------------------------------- +# Domain: FOLLOWUP (Deadline Tracking & Nudges) +# --------------------------------------------------------------------------- +def cmd_followup_list(args): + followups = {} + if FOLLOWUPS_FILE.exists(): + try: + with open(FOLLOWUPS_FILE, "r") as f: + followups = json.load(f) + except Exception: + pass + + if getattr(args, "json", False): + print(json.dumps(followups, indent=2)) + return + + now_str = datetime.now().strftime("%H:%M:%S") + print(f"\n=== FOLLOW-UP TRACKING === ({now_str} local)\n") + + headers = ["DM ID", "ROUTE", "TARGET", "NUDGES", "DEADLINE", "STATUS", "SNIPPET"] + rows = [] + now = datetime.now(timezone.utc) + + for dm_id, rec in sorted(followups.items(), key=lambda x: x[1].get("sent_at", ""), reverse=True): + st = rec.get("status", "pending") + if not getattr(args, "all", False) and st not in ("pending", "escalated"): + continue + + route = f"{rec.get('sender', '?')} → {c_bold(rec.get('recipient', '?'))}" + target = rec.get("target", "main") + if len(target) > 18: + target = target[:17] + "…" + nudges = f"{rec.get('nudges_sent', 0)}/{rec.get('nudges_allowed', 2)}" + + deadline_str = rec.get("deadline") + deadline_display = c_dim("-") + if deadline_str and st == "pending": + try: + dl_dt = datetime.fromisoformat(deadline_str.replace("Z", "+00:00")) + diff = int((dl_dt - now).total_seconds()) + if diff > 0: + deadline_display = c_green(f"in {diff // 60}m") + else: + deadline_display = c_red(f"{abs(diff) // 60}m overdue") + except Exception: + deadline_display = deadline_str[:16] + + if st == "pending": + status_badge = badge_warn("PENDING") + elif st == "escalated": + status_badge = badge_err("ESCALATED") + elif st == "resolved": + status_badge = badge_ok("RESOLVED") + else: + status_badge = badge_dim(st.upper()) + + snippet = rec.get("prompt_snippet") or rec.get("resolved_snippet") or "" + snippet = snippet.replace("\n", " ")[:35] + + rows.append([ + c_bold(dm_id[:8]), + route, + target, + nudges, + deadline_display, + status_badge, + c_dim(snippet) + ]) + + print_table(headers, rows) + if not getattr(args, "all", False): + print(c_dim("\n (Showing pending & escalated only; use --all to view resolved records)")) + print(c_dim(" Commands: super followup sweep | super followup cancel <dm_id>\n")) + +def cmd_followup_sweep(args): + cmd = [sys.executable, str(FOLLOWUP_SWEEPER_PY), "--once"] + if getattr(args, "dry_run", False): + cmd.append("--dry-run") + try: + res = subprocess.run(cmd, capture_output=True, text=True) + if res.stdout.strip(): + print(res.stdout.strip()) + if res.stderr.strip(): + print(c_yellow(res.stderr.strip()), file=sys.stderr) + except Exception as e: + print(c_red(f"Error running sweeper: {e}"), file=sys.stderr) + sys.exit(1) + +def cmd_followup_cancel(args): + if not FOLLOWUPS_FILE.exists(): + print(c_red("No follow-ups found."), file=sys.stderr) + sys.exit(1) + try: + with open(FOLLOWUPS_FILE, "r") as f: + data = json.load(f) + except Exception as e: + print(c_red(f"Error reading follow-ups: {e}"), file=sys.stderr) + sys.exit(1) + + target_id = args.dm_id.strip() + match_key = None + for k in data.keys(): + if k == target_id or k.startswith(target_id): + match_key = k + break + + if not match_key: + print(c_red(f"Follow-up ID '{target_id}' not found."), file=sys.stderr) + sys.exit(1) + + data[match_key]["status"] = "canceled" + data[match_key]["canceled_at"] = datetime.now(timezone.utc).isoformat() + tmp_path = f"{FOLLOWUPS_FILE}.tmp.{os.getpid()}" + with open(tmp_path, "w") as f: + json.dump(data, f, indent=2) + os.replace(tmp_path, FOLLOWUPS_FILE) + print(c_green(f"● Cancelled follow-up {match_key}.")) + +# --------------------------------------------------------------------------- +# Domain: LOOP (Intrinsic Loop Strategy, Health, Break Taxonomy, and Control) +# --------------------------------------------------------------------------- + +def cmd_loop_status(args): + agent = getattr(args, "agent", None) + status_filter = getattr(args, "status", None) + limit = getattr(args, "n", 30) + + from gravity import reconstruct_loops + loops = reconstruct_loops(limit=limit, agent=agent, status_filter=status_filter) + + if getattr(args, "json", False): + print(json.dumps({"ok": True, "loops": loops, "count": len(loops)}, indent=2)) + return + + prog = Path(sys.argv[0]).name if sys.argv and sys.argv[0] else "super" + if prog.endswith(".py"): + prog = "super" + + print("\n" + c_bold(f"=== INTRINSIC LOOPS ({len(loops)} tracked) ===") + "\n") + headers = ["LOOP ID", "AGENT", "TARGET", "PURPOSE", "STATE", "AGE", "DEADLINE", "NUDGES", "DETAIL"] + rows = [] + now = datetime.now(timezone.utc) + + for l in loops: + lid = l.get("loop_id", "-")[:8] + ag = l.get("agent", "-") + tgt = l.get("target", "main") + if len(tgt) > 16: + tgt = tgt[:14] + ".." + purp = l.get("purpose", "-") + st = l.get("state", "LANDED") + if st == "FIRING": + st_badge = c_cyan("○ FIRING") + elif st == "LANDED": + st_badge = c_blue("● LANDED") + elif st == "SEEN": + st_badge = c_yellow("● SEEN") + elif st in ("ANSWERED", "CLOSED"): + st_badge = badge_ok(st) + elif st == "NUDGED": + st_badge = badge_warn("NUDGED") + elif st == "ESCALATED": + st_badge = badge_err("ESCALATED") + elif st == "BROKEN": + st_badge = badge_err("BROKEN") + else: + st_badge = badge_dim(st) + + sent_rel = parse_relative_time(l.get("sent_at", "")) + dl_str = l.get("deadline", "") + if dl_str: + try: + dl_dt = datetime.fromisoformat(dl_str.replace("Z", "+00:00")) + diff_s = int((dl_dt - now).total_seconds()) + if diff_s > 0: + dl_disp = c_green(f"in {diff_s // 60}m") + else: + dl_disp = c_red(f"{abs(diff_s) // 60}m ago") + except Exception: + dl_disp = dl_str[:10] + else: + dl_disp = c_dim("-") + + nudges = f"{l.get('nudges_sent', 0)}/{l.get('nudges_allowed', 2)}" + detail = l.get("summary", "")[:30] + + rows.append([c_bold(lid), ag, tgt, purp, st_badge, sent_rel, dl_disp, nudges, c_dim(detail)]) + + print_table(headers, rows) + print(c_dim(f"\n Commands: {prog} loop health | {prog} loop breaks | {prog} loop strat | {prog} loop vars | {prog} loop close <id>\n")) + + +def cmd_loop_health(args): + threshold = getattr(args, "threshold", None) + from gravity import get_fleet_loop_health + data = get_fleet_loop_health(threshold=threshold) + + if getattr(args, "json", False): + print(json.dumps({"ok": True, **data}, indent=2)) + return + + summary = data.get("summary", {}) + t_val = summary.get("threshold", 0.5) + + print("\n" + c_bold(f"=== INTRINSIC FLEET LOOP HEALTH (Threshold: {t_val:.2f}) ===") + "\n") + headers = ["AGENT", "STATUS", "HEALTH RATIO", "LANDED", "ANSWERED", "VERDICT"] + rows = [] + + for ag, info in sorted(data.get("agents", {}).items()): + st = info.get("status", "IDLE") + if st == "HEALTHY": + badge = badge_ok("HEALTHY") + verd = c_green("PASS") + elif st == "DEGRADED": + badge = badge_err("DEGRADED") + verd = c_red("FAIL") + else: + badge = badge_dim("IDLE") + verd = c_dim("NO TRAFFIC") + + hpct = info.get("health_pct", "-") + landed = str(info.get("landed", 0)) + answered = str(info.get("answered", 0)) + rows.append([c_bold(ag), badge, hpct, landed, answered, verd]) + + print_table(headers, rows) + ov = summary.get("overall_health_pct", "-") + tot_l = summary.get("total_landed", 0) + tot_a = summary.get("total_answered", 0) + fleet_st = c_green("HEALTHY") if summary.get("healthy") else c_red("DEGRADED") + print(f"\n {c_bold('Overall Fleet Health')}: {ov} ({tot_a}/{tot_l} loops closed/answered) — {fleet_st}\n") + + +def cmd_loop_breaks(args): + from gravity import diagnose_breaks + breaks = diagnose_breaks() + + if getattr(args, "json", False): + print(json.dumps({"ok": True, "breaks": breaks, "count": len(breaks)}, indent=2)) + return + + if not breaks: + print("\n" + c_green("✔ All intrinsic loops and tracking mechanisms healthy. No breaks detected.") + "\n") + return + + print("\n" + c_bold(f"=== INTRINSIC LOOP BREAK DIAGNOSTICS ({len(breaks)} issues) ===") + "\n") + headers = ["SEVERITY", "BREAK TYPE", "COMPONENT / AGENT", "DIAGNOSIS", "REMEDIATION"] + rows = [] + for b in breaks: + sev = badge_err(b.get("severity", "CRITICAL")) if b.get("severity") == "CRITICAL" else badge_warn("WARNING") + btype = b.get("type", "-") + target = b.get("component") or b.get("agent") or "-" + diag = b.get("detail", "") + rem = b.get("remedy", "") + rows.append([sev, c_bold(btype), target, diag, c_dim(rem)]) + print_table(headers, rows) + print() + + +def cmd_loop_strat(args): + subact = getattr(args, "strat_action", None) or "show" + from modulate import get_all_strategies, set_strategy_override, reset_strategy_override, modulate, render_tags, InputType + + prog = Path(sys.argv[0]).name if sys.argv and sys.argv[0] else "super" + if prog.endswith(".py"): + prog = "super" + + if subact == "show": + strats = get_all_strategies() + filter_type = getattr(args, "type", None) + if filter_type: + strats = [s for s in strats if s.get("input_type") == filter_type.lower()] + + if getattr(args, "json", False): + print(json.dumps({"ok": True, "strategies": strats}, indent=2)) + return + + print("\n" + c_bold("=== INTRINSIC LOOP STRATEGY MATRIX (MODULATION) ===") + "\n") + headers = ["INPUT TYPE", "SUBTYPE", "AGENT", "TRACK", "PRIORITY", "TIMEOUT", "NUDGES", "ESCALATE", "SOURCE"] + rows = [] + for s in strats: + itype = s.get("input_type", "") + sub = s.get("subtype") or "-" + ag = s.get("agent") or "-" + tr = str(s.get("track", "-")) + prio = s.get("priority", "normal") + if prio == "critical": + prio_disp = c_red("CRITICAL") + elif prio == "important": + prio_disp = c_yellow("IMPORTANT") + elif prio == "routine": + prio_disp = c_dim("ROUTINE") + else: + prio_disp = c_cyan(prio.upper()) + + timeout = f"{s.get('timeout_s', 0)}s" + nudges = str(s.get("nudges", 0)) + esc = s.get("escalate") or "-" + src = c_yellow("OVERRIDE") if s.get("is_override") else c_dim("BUILTIN") + + rows.append([c_bold(itype), sub, ag, tr, prio_disp, timeout, nudges, esc, src]) + + print_table(headers, rows) + print(c_dim(f"\n Modify: {prog} loop strat set <type> [--subtype S] [--agent A] [--priority P] [--timeout S] [--nudges N] [--escalate AGENT]\n")) + + elif subact == "set": + itype = args.type + sub = getattr(args, "subtype", None) + agent = getattr(args, "agent", None) + res = set_strategy_override( + itype, sub, agent, + track=getattr(args, "track", None), + priority=getattr(args, "priority", None), + timeout_s=getattr(args, "timeout", None), + nudges=getattr(args, "nudges", None), + escalate=getattr(args, "escalate", None), + by="super" + ) + target_desc = f"{itype}:{sub or '*'}:{agent or '*'}" if agent else f"{itype}:{sub or '*'}" + print(c_green(f"✔ Updated loop strategy override for '{target_desc}'.")) + if getattr(args, "json", False): + print(json.dumps({"ok": True, "strategy": res}, indent=2)) + + elif subact == "reset": + itype = args.type + sub = getattr(args, "subtype", None) + agent = getattr(args, "agent", None) + ok = reset_strategy_override(itype, sub, agent, by="super") + target_desc = f"{itype}:{sub or '*'}:{agent or '*'}" if agent else f"{itype}:{sub or '*'}" + if ok: + print(c_green(f"✔ Reset loop strategy override for '{target_desc}' to default.")) + else: + print(c_yellow(f"No override was active for '{target_desc}'.")) + + elif subact == "eval": + itype_str = args.type + sub = getattr(args, "subtype", None) + agent = getattr(args, "agent", None) + actionable = getattr(args, "actionable", False) + try: + itype = InputType(itype_str.lower()) + except ValueError: + itype = InputType.MANUAL + pol = modulate(itype, sub.upper() if sub else None, agent=agent, actionable=actionable) + if getattr(args, "json", False): + print(json.dumps({"ok": True, "policy": pol.__dict__ if pol else None}, indent=2)) + return + if pol: + print(f"\n{c_bold('Resolved Policy:')}") + print(f" Input Type : {pol.input_type.value}") + print(f" Subtype : {pol.subtype or '-'}") + print(f" Agent Scope: {agent or '-'}") + print(f" Priority : {pol.priority.value}") + print(f" Timeout : {pol.timeout_s}s") + print(f" Max Nudges : {pol.nudges}") + print(f" Escalate To: {pol.escalate or '-'}") + print(f" Rendered Tags: {c_cyan(render_tags(pol))}\n") + else: + print(f"\n{c_dim('Policy: Non-tracked input (no follow-up record created).')}\n") + + +def cmd_loop_vars(args): + subact = getattr(args, "vars_action", None) or "list" + from variables import Variables + v = Variables() + + prog = Path(sys.argv[0]).name if sys.argv and sys.argv[0] else "super" + if prog.endswith(".py"): + prog = "super" + + if subact == "list": + if getattr(args, "json", False): + print(json.dumps({"ok": True, "variables": v.all(), "schemas": {n: v.schema(n) for n in v.names()}}, indent=2)) + return + + names = v.names() + print("\n" + c_bold(f"=== RUNTIME CONTROL VARIABLES ({len(names)} registered) ===") + "\n") + headers = ["VARIABLE NAME", "VALUE", "DEFAULT", "UNIT", "RANGE", "DESCRIPTION"] + rows = [] + + for name in names: + val = v.get(name) + spec = v.schema(name) + d_val = spec.get("default") + unit = spec.get("unit", "-") + min_v = spec.get("min") + max_v = spec.get("max") + rng = f"{min_v}..{max_v}" if min_v is not None and max_v is not None else "-" + desc = spec.get("description", "")[:40] + + val_str = str(val) + if val != d_val: + val_disp = c_cyan(f"{val_str} *") + else: + val_disp = val_str + + rows.append([c_bold(name), val_disp, str(d_val), unit, rng, c_dim(desc)]) + + print_table(headers, rows) + print(c_dim(f"\n Adjust: {prog} loop vars set <name> <value> | Reset: {prog} loop vars reset <name> (* = modified)\n")) + + elif subact == "get": + name = args.name + try: + val = v.get(name) + spec = v.schema(name) + if getattr(args, "json", False): + print(json.dumps({"ok": True, "name": name, "value": val, "schema": spec}, indent=2)) + return + print(f"\n{c_bold('Variable: ' + name)}") + print(f" Current Value : {c_cyan(str(val))}") + print(f" Default Value : {spec.get('default')}") + print(f" Type : {spec.get('type')}") + print(f" Unit : {spec.get('unit', '-')}") + if "min" in spec and "max" in spec: + print(f" Allowed Range : {spec.get('min')} .. {spec.get('max')}") + print(f" Description : {spec.get('description')}\n") + except Exception as e: + print(c_red(f"Error: {e}"), file=sys.stderr) + sys.exit(1) + + elif subact == "set": + name = args.name + val_raw = args.value + try: + spec = v.schema(name) + vtype = spec.get("type", "str") + if vtype == "int": + val = int(val_raw) + elif vtype == "float": + val = float(val_raw) + elif vtype == "bool": + val = val_raw.lower() in ("true", "1", "yes") + else: + val = val_raw + new_val = v.set(name, val, by="super") + print(c_green(f"✔ Variable '{name}' updated to {new_val}.")) + if getattr(args, "json", False): + print(json.dumps({"ok": True, "name": name, "value": new_val}, indent=2)) + except Exception as e: + print(c_red(f"Error setting variable: {e}"), file=sys.stderr) + sys.exit(1) + + elif subact == "reset": + name = args.name + try: + def_val = v.reset(name, by="super") + print(c_green(f"✔ Variable '{name}' reset to default ({def_val}).")) + if getattr(args, "json", False): + print(json.dumps({"ok": True, "name": name, "value": def_val}, indent=2)) + except Exception as e: + print(c_red(f"Error resetting variable: {e}"), file=sys.stderr) + sys.exit(1) + + elif subact == "history": + name = getattr(args, "name", None) + limit = getattr(args, "limit", 20) + entries = v.history(name=name, limit=limit) + if getattr(args, "json", False): + print(json.dumps({"ok": True, "history": entries, "count": len(entries)}, indent=2)) + return + if not entries: + print("\n" + c_dim(" (no variable change history found)") + "\n") + return + print("\n" + c_bold(f"=== VARIABLE MODIFICATION AUDIT HISTORY ({len(entries)} entries) ===") + "\n") + headers = ["TIMESTAMP", "VARIABLE", "ACTION", "OLD VALUE", "NEW VALUE", "CHANGED BY"] + rows = [] + for e in entries: + act = e.get("action", "") + if act == "set": + act_badge = c_cyan("SET") + elif act == "reset": + act_badge = c_yellow("RESET") + elif act == "rollback": + act_badge = c_magenta("ROLLBACK") + else: + act_badge = act + rows.append([ + e.get("ts", "")[:19].replace("T", " "), + c_bold(e.get("name", "")), + act_badge, + str(e.get("old_value", "-")), + c_bold(str(e.get("new_value", "-"))), + e.get("by", "-") + ]) + print_table(headers, rows) + print(c_dim(f"\n Rollback: {prog} loop vars rollback <name> [--revision REV]\n")) + + elif subact == "rollback": + name = args.name + rev = getattr(args, "revision", None) + try: + new_val = v.rollback(name, revision=rev, by="super") + print(c_green(f"✔ Variable '{name}' rolled back to {new_val}.")) + if getattr(args, "json", False): + print(json.dumps({"ok": True, "name": name, "value": new_val}, indent=2)) + except Exception as e: + print(c_red(f"Error rolling back variable: {e}"), file=sys.stderr) + sys.exit(1) + + +def cmd_loop_close(args): + dm_id = args.dm_id.strip() + cmd = [sys.executable, str(BIN_DIR / "box-ctl.py"), "loop-resolve", dm_id] + if getattr(args, "note", None): + cmd.append(args.note) + res = subprocess.run(cmd, capture_output=True, text=True) + try: + data = json.loads(res.stdout) + if data.get("ok"): + print(c_green(f"✔ Loop {dm_id} marked resolved/closed.")) + else: + print(c_red(f"Error closing loop: {data.get('error')}"), file=sys.stderr) + except Exception: + print(res.stdout or res.stderr) + + +def cmd_loop_nudge(args): + dm_id = args.dm_id.strip() + if not FOLLOWUPS_FILE.exists(): + print(c_red("followups.json not found."), file=sys.stderr) + return + try: + with open(FOLLOWUPS_FILE, "r") as f: + fdata = json.load(f) + rec = fdata.get(dm_id) + if not rec: + print(c_red(f"No pending follow-up found for ID '{dm_id}'."), file=sys.stderr) + return + rec["deadline"] = datetime.now(timezone.utc).isoformat() + with open(FOLLOWUPS_FILE, "w") as f: + json.dump(fdata, f, indent=2) + print(c_green(f"✔ Armed immediate nudge for loop {dm_id}. Running sweeper...")) + subprocess.run([sys.executable, str(FOLLOWUP_SWEEPER_PY), "--once"]) + except Exception as e: + print(c_red(f"Error nudging loop: {e}"), file=sys.stderr) + + +def cmd_loop_sweep(args): + cmd_followup_sweep(args) + + +def cmd_loop_harvest(args): + cmd_harvest_run(args) + + +def cmd_loop_remediate(args): + dry_run = getattr(args, "dry_run", False) + from gravity import remediate_breaks + res = remediate_breaks(dry_run=dry_run) + + if getattr(args, "json", False): + print(json.dumps({"ok": True, **res}, indent=2)) + return + + mode_str = c_yellow("DRY RUN (Simulated)") if dry_run else c_green("ACTIVE RECOVERY") + print("\n" + c_bold(f"=== PROGRESSIVE INTRINSIC LOOP REMEDIATION [{mode_str}] ===") + "\n") + + remediated = res.get("remediated", []) + escalated = res.get("escalated", []) + + if remediated: + print(c_bold(f"Auto-Remediated Soft Breakages ({len(remediated)}):")) + headers = ["ACTION", "LOOP ID", "AGENT", "REMEDIATION DETAIL"] + rows = [] + for r in remediated: + act_badge = c_green("✔ " + r.get("action", "")) + rows.append([act_badge, c_bold(r.get("loop_id", "-")[:8]), r.get("agent", "-"), r.get("detail", "")]) + print_table(headers, rows) + print() + else: + print(c_dim(" ✔ No soft breakages require remediation at this time.\n")) + + if escalated: + print(c_red(c_bold(f"⚠ Escalated Hard Failures ({len(escalated)} - Manual Intervention Required):"))) + headers = ["SEVERITY", "BREAK TYPE", "TARGET", "ISSUE DETAIL", "REMEDY"] + rows = [] + for esc in escalated: + sev = badge_err("CRITICAL") if esc.get("severity") == "CRITICAL" else badge_warn("WARNING") + rows.append([sev, c_bold(esc.get("type", "-")), esc.get("agent") or esc.get("component") or "-", esc.get("detail", ""), c_dim(esc.get("remedy", ""))]) + print_table(headers, rows) + print() + +# --------------------------------------------------------------------------- +# CLI Argument Parser Setup +# --------------------------------------------------------------------------- +def build_parser(): + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--json", action="store_true", help="Output machine-readable JSON") + + prog_name = Path(sys.argv[0]).name if sys.argv and sys.argv[0] else "super" + if prog_name.endswith(".py"): + prog_name = "super" + + parser = argparse.ArgumentParser( + prog=prog_name, + description=f"{prog_name} — Unified Orchestrator CLI for NetVM & Box", + formatter_class=argparse.RawDescriptionHelpFormatter, + parents=[common] + ) + subparsers = parser.add_subparsers(dest="domain", help="Orchestration Domain") + + # Domain: FLEET + p_fleet = subparsers.add_parser("fleet", parents=[common], help="Node health, CDP status, active tabs, watch, restart") + p_fleet.add_argument("action", nargs="?", default="status", choices=["status", "watch", "restart", "cdp"]) + p_fleet.add_argument("node", nargs="?", default=None, help="Target node (for restart / cdp)") + p_fleet.add_argument("--interval", type=int, default=2, help="Watch refresh interval in seconds") + + # Domain: DM + p_dm = subparsers.add_parser("dm", parents=[common], help="Inter-agent DMs, work orders ([WO]), acks, live log tail") + dm_sub = p_dm.add_subparsers(dest="action") + + # super dm send + p_dm_send = dm_sub.add_parser("send", parents=[common], help="Send a direct message") + p_dm_send.add_argument("--to", required=True, choices=VALID_NODES, help="Recipient node") + p_dm_send.add_argument("--from", dest="from_agent", default=DEFAULT_SENDER, help="Sender identity (default: super)") + p_dm_send.add_argument("--target", default="main", help="Target chat/sidechat (default: main)") + p_dm_send.add_argument("-w", "--wait", action="store_true", help="Wait for recipient agent to reply") + p_dm_send.add_argument("-t", "--timeout", type=int, default=60, help="Wait timeout in seconds (default: 60)") + p_dm_send.add_argument("--expect-reply", action="store_true", help="Arm follow-up tracking") + p_dm_send.add_argument("--reply-timeout", type=int, default=None, help="Timeout in seconds") + p_dm_send.add_argument("--no-sign", action="store_true", help="Send without cryptographic SSH signature") + p_dm_send.add_argument("message", help="Message body") + + # super dm chat + p_dm_chat = dm_sub.add_parser("chat", parents=[common], help="Interactive conversational chat session with an agent") + p_dm_chat.add_argument("agent", choices=VALID_NODES, help="Target agent to converse with") + p_dm_chat.add_argument("--target", default=None, help="Target sidechat or 'main'") + p_dm_chat.add_argument("-t", "--timeout", type=int, default=60, help="Reply timeout in seconds (default: 60)") + + # super dm send-file + p_dm_sf = dm_sub.add_parser("send-file", parents=[common], help="Transfer a file to an agent and send a pointer in chat") + p_dm_sf.add_argument("--to", required=True, choices=VALID_NODES, help="Recipient node") + p_dm_sf.add_argument("--target", default=None, help="Target sidechat (default: agent primary task sidechat)") + p_dm_sf.add_argument("--note", default=None, help="Optional operator note or instructions") + p_dm_sf.add_argument("-w", "--wait", action="store_true", help="Wait for recipient agent to reply/acknowledge") + p_dm_sf.add_argument("-t", "--timeout", type=int, default=60, help="Reply timeout in seconds (default: 60)") + p_dm_sf.add_argument("file", help="Path to file to transfer") + + # super dm files + p_dm_files = dm_sub.add_parser("files", parents=[common], help="List transferred files and staged payloads") + p_dm_files.add_argument("--agent", choices=VALID_NODES, default=None, help="Filter by recipient agent") + + # super dm wo + p_dm_wo = dm_sub.add_parser("wo", parents=[common], help="Dispatch a structured Work Order ([WO:...])") + p_dm_wo.add_argument("--to", required=True, choices=VALID_NODES, help="Recipient node") + p_dm_wo.add_argument("--title", required=True, help="Work Order title") + p_dm_wo.add_argument("--priority", choices=["routine", "urgent"], default="routine", help="Priority level") + p_dm_wo.add_argument("--from", dest="from_agent", default=DEFAULT_SENDER, help="Sender identity (default: super)") + p_dm_wo.add_argument("--target", default="main", help="Target chat (default: main)") + p_dm_wo.add_argument("--no-sign", action="store_true", help="Issue without cryptographic SSH signature") + p_dm_wo.add_argument("body", help="Work Order detailed description") + + # super dm ack + p_dm_ack = dm_sub.add_parser("ack", parents=[common], help="Acknowledge a DM or Work Order") + p_dm_ack.add_argument("id", help="DM ID or Work Order ID") + p_dm_ack.add_argument("--to", required=True, choices=VALID_NODES, help="Recipient node") + p_dm_ack.add_argument("--from", dest="from_agent", default=DEFAULT_SENDER, help="Sender identity (default: super)") + p_dm_ack.add_argument("--target", default="main", help="Target chat (default: main)") + + # super dm log + p_dm_log = dm_sub.add_parser("log", parents=[common], help="Display recent DM activity") + p_dm_log.add_argument("-n", type=int, default=25, help="Number of records to show") + p_dm_log.add_argument("--agent", choices=VALID_NODES, default=None, help="Filter by agent") + p_dm_log.add_argument("--filter", default=None, help="Search filter in text") + + # super dm tail + p_dm_tail = dm_sub.add_parser("tail", parents=[common], help="Live stream incoming DMs") + p_dm_tail.add_argument("--filter", default=None, help="Search filter in text") + + # super dm verify + p_dm_verify = dm_sub.add_parser("verify", parents=[common], help="Verify cryptographic signature on a DM") + p_dm_verify.add_argument("message", nargs="?", default=None, help="Message text containing signature block") + p_dm_verify.add_argument("--agent", choices=VALID_NODES, default=None, help="Scan agent's recent reads") + p_dm_verify.add_argument("--target", default=None, help="Scan target chat's recent reads") + + # Domain: THREAD + p_thread = subparsers.add_parser("thread", parents=[common], help="Inspect agent main chats, sidechats, and scrollbacks") + thread_sub = p_thread.add_subparsers(dest="action") + + p_thread_list = thread_sub.add_parser("list", parents=[common], help="List active threads for an agent") + p_thread_list.add_argument("agent", choices=VALID_NODES, help="Agent node to inspect") + + p_thread_view = thread_sub.add_parser("view", parents=[common], help="View recent messages from an agent's thread") + p_thread_view.add_argument("agent", choices=VALID_NODES, help="Agent node to inspect") + p_thread_view.add_argument("thread_id", help="Thread UUID or 'main'") + p_thread_view.add_argument("--limit", type=int, default=15, help="Number of messages to retrieve") + + # Domain: JOB + p_job = subparsers.add_parser("job", parents=[common], help="Manage scheduled jobs, systemd timers, triggers, logs") + job_sub = p_job.add_subparsers(dest="action") + + p_job_list = job_sub.add_parser("list", parents=[common], help="List defined jobs") + + p_job_show = job_sub.add_parser("show", parents=[common], help="Inspect full job definition and timer details") + p_job_show.add_argument("name", help="Job name") + + p_job_status = job_sub.add_parser("status", parents=[common], help="Check timer status") + p_job_status.add_argument("name", nargs="?", default=None, help="Job/timer name") + + p_job_create = job_sub.add_parser("create", parents=[common], help="Create or update a scheduled job") + p_job_create.add_argument("name", help="Job name (^[a-z0-9-]{1,64}$)") + p_job_create.add_argument("--agent", choices=VALID_NODES, default=None, help="Target agent") + p_job_create.add_argument("--schedule", default=None, help="Cron schedule (e.g. '*/15 * * * *' or 'manual')") + p_job_create.add_argument("--prompt", default=None, help="Prompt template") + p_job_create.add_argument("--description", default=None, help="Job description") + p_job_create.add_argument("--timeout", type=int, default=300, help="Timeout in seconds (60-3600, default: 300)") + p_job_create.add_argument("--on-failure", choices=["alert", "retry", "ignore"], default="alert", help="Failure policy") + p_job_create.add_argument("--chain-next", default=None, help="Next job name to chain on success") + p_job_create.add_argument("--sidechat-create", action="store_true", help="Dispatch in a sidechat") + p_job_create.add_argument("--sidechat-name", default=None, help="Sidechat name template") + p_job_create.add_argument("--reuse-key", default=None, help="Sidechat reuse key") + p_job_create.add_argument("--dm-target", default=None, help="Target chat or thread name") + p_job_create.add_argument("--file", default=None, help="Path to JSON file with job definition (or '-' for stdin)") + p_job_create.add_argument("--no-enable", action="store_true", help="Do not enable systemd timer immediately") + p_job_create.add_argument("--update", action="store_true", help="Allow updating an existing job") + + p_job_enable = job_sub.add_parser("enable", parents=[common], help="Enable and activate a job's systemd timer") + p_job_enable.add_argument("name", help="Job name") + + p_job_disable = job_sub.add_parser("disable", parents=[common], help="Stop and disable a job's systemd timer") + p_job_disable.add_argument("name", help="Job name") + + p_job_delete = job_sub.add_parser("delete", parents=[common], help="Delete a job and its timer") + p_job_delete.add_argument("name", help="Job name") + p_job_delete.add_argument("-y", "--yes", action="store_true", help="Bypass confirmation prompt") + p_job_delete.add_argument("--force", action="store_true", help="Force deletion even if timer error") + + p_job_run = job_sub.add_parser("run", parents=[common], help="Trigger a job immediately") + p_job_run.add_argument("name", help="Job name to trigger") + p_job_run.add_argument("-f", "--follow", action="store_true", help="Stream live dispatch and execution events") + + p_job_log = job_sub.add_parser("log", parents=[common], help="View recent job execution events") + p_job_log.add_argument("-n", type=int, default=20, help="Number of events") + p_job_log.add_argument("name", nargs="?", default=None, help="Filter by job name") + + # Domain: WEB + p_web = subparsers.add_parser("web", parents=[common], help="Probe VM web surfaces (box.muse-dev.online), auth, sync") + web_sub = p_web.add_subparsers(dest="action") + + web_sub.add_parser("health", parents=[common], help="Check Google Cloud VM web surface health") + web_sub.add_parser("test-auth", parents=[common], help="Verify operator auth gating on the VM") + web_sub.add_parser("sync", parents=[common], help="Check local log size and sync integrity") + + # Domain: HARVEST + p_harvest = subparsers.add_parser("harvest", parents=[common], help="Readback & response harvesting engine") + harvest_sub = p_harvest.add_subparsers(dest="action") + p_h_run = harvest_sub.add_parser("run", parents=[common], help="Run a harvest cycle immediately") + p_h_run.add_argument("--agent", choices=VALID_NODES, default=None, help="Target specific agent") + p_h_run.add_argument("--dry-run", action="store_true", help="Scrape without persisting watermarks") + + p_h_status = harvest_sub.add_parser("status", parents=[common], help="Display active watermarks and harvest metrics") + + p_h_tail = harvest_sub.add_parser("tail", parents=[common], help="Live stream ingested chat messages") + p_h_tail.add_argument("--filter", default=None, help="Search filter in text") + + # Domain: FOLLOWUP + p_followup = subparsers.add_parser("followup", parents=[common], help="Scheduled follow-up deadline tracking and nudges") + followup_sub = p_followup.add_subparsers(dest="action") + p_f_list = followup_sub.add_parser("list", parents=[common], help="List tracked follow-ups and deadlines") + p_f_list.add_argument("--all", action="store_true", help="Include resolved and historical records") + + p_f_sweep = followup_sub.add_parser("sweep", parents=[common], help="Trigger deadline check and nudge dispatch immediately") + p_f_sweep.add_argument("--dry-run", action="store_true", help="Simulate sweep without sending DMs") + + p_f_cancel = followup_sub.add_parser("cancel", parents=[common], help="Cancel a pending follow-up") + p_f_cancel.add_argument("dm_id", help="DM ID to cancel") + + # Domain: LOOP (Intrinsic Loop Management) + p_loop = subparsers.add_parser("loop", parents=[common], help="Intrinsic loop strategy, health, break taxonomy, and variables") + loop_sub = p_loop.add_subparsers(dest="action") + + p_l_status = loop_sub.add_parser("status", parents=[common], help="Show active and recent intrinsic loops") + p_l_status.add_argument("--agent", choices=VALID_NODES, default=None, help="Filter by agent") + p_l_status.add_argument("--status", choices=["pending", "active", "nudged", "escalated", "closed", "resolved", "all"], default=None, help="Filter by state") + p_l_status.add_argument("-n", type=int, default=30, help="Number of loops to display") + + p_l_health = loop_sub.add_parser("health", parents=[common], help="Evaluate per-agent and fleet loop health metrics") + p_l_health.add_argument("--threshold", type=float, default=None, help="Health ratio threshold (default: 0.5)") + + p_l_breaks = loop_sub.add_parser("breaks", parents=[common], help="Run break detection diagnostics across loops") + + # loop strat + p_l_strat = loop_sub.add_parser("strat", parents=[common], help="Inspect and configure loop modulation strategies") + strat_sub = p_l_strat.add_subparsers(dest="strat_action") + p_ls_show = strat_sub.add_parser("show", parents=[common], help="Display active modulation strategy matrix") + p_ls_show.add_argument("type", nargs="?", default=None, help="Filter by input type (wake, job, siphon, manual, health, heartbeat)") + + p_ls_set = strat_sub.add_parser("set", parents=[common], help="Override loop modulation strategy") + p_ls_set.add_argument("type", help="Input type (wake, job, siphon, manual, health, heartbeat)") + p_ls_set.add_argument("--subtype", default=None, help="Subtype (e.g. overdue, canary, alert, blocker)") + p_ls_set.add_argument("--agent", choices=VALID_NODES, default=None, help="Target agent scope") + p_ls_set.add_argument("--track", choices=["true", "false", "actionable", "always", "never"], default=None, help="Track mode") + p_ls_set.add_argument("--priority", choices=["routine", "normal", "important", "critical"], default=None, help="Priority rank") + p_ls_set.add_argument("--timeout", type=int, default=None, help="Timeout in seconds before nudge") + p_ls_set.add_argument("--nudges", type=int, default=None, help="Max automatic nudges") + p_ls_set.add_argument("--escalate", default=None, help="Escalation target identity (e.g. opm, user, none)") + + p_ls_reset = strat_sub.add_parser("reset", parents=[common], help="Reset loop strategy override to builtin default") + p_ls_reset.add_argument("type", help="Input type") + p_ls_reset.add_argument("--subtype", default=None, help="Subtype") + p_ls_reset.add_argument("--agent", choices=VALID_NODES, default=None, help="Target agent scope") + + p_ls_eval = strat_sub.add_parser("eval", parents=[common], help="Evaluate/simulate strategy resolution for input") + p_ls_eval.add_argument("type", help="Input type") + p_ls_eval.add_argument("--subtype", default=None, help="Subtype") + p_ls_eval.add_argument("--agent", choices=VALID_NODES, default=None, help="Target agent scope") + p_ls_eval.add_argument("--actionable", action="store_true", help="Actionable flag (for wake)") + + # loop vars + p_l_vars = loop_sub.add_parser("vars", parents=[common], help="Inspect and adjust runtime control variables") + vars_sub = p_l_vars.add_subparsers(dest="vars_action") + vars_sub.add_parser("list", parents=[common], help="List all registered control variables") + + p_lv_get = vars_sub.add_parser("get", parents=[common], help="Inspect a specific control variable") + p_lv_get.add_argument("name", help="Variable name") + + p_lv_set = vars_sub.add_parser("set", parents=[common], help="Set a control variable value") + p_lv_set.add_argument("name", help="Variable name") + p_lv_set.add_argument("value", help="New value") + + p_lv_reset = vars_sub.add_parser("reset", parents=[common], help="Reset a control variable to default") + p_lv_reset.add_argument("name", help="Variable name") + + p_lv_hist = vars_sub.add_parser("history", parents=[common], help="Show variable modification audit history") + p_lv_hist.add_argument("name", nargs="?", default=None, help="Variable name") + p_lv_hist.add_argument("-n", "--limit", type=int, default=20, help="Number of records") + + p_lv_rb = vars_sub.add_parser("rollback", parents=[common], help="Roll back a variable to a previous value") + p_lv_rb.add_argument("name", help="Variable name") + p_lv_rb.add_argument("--revision", default=None, help="Revision step (int) or timestamp") + + p_l_remed = loop_sub.add_parser("remediate", parents=[common], help="Run progressive auto-remediation for soft breaks") + p_l_remed.add_argument("--dry-run", action="store_true", help="Simulate remediation without applying fixes") + + p_l_close = loop_sub.add_parser("close", parents=[common], help="Manually close/resolve a pending loop") + p_l_close.add_argument("dm_id", help="DM ID or Loop ID") + p_l_close.add_argument("--note", default=None, help="Resolution note") + + p_l_nudge = loop_sub.add_parser("nudge", parents=[common], help="Trigger immediate nudge for a pending loop") + p_l_nudge.add_argument("dm_id", help="DM ID or Loop ID") + + p_l_sweep = loop_sub.add_parser("sweep", parents=[common], help="Run immediate sweep cycle") + p_l_sweep.add_argument("--dry-run", action="store_true", help="Simulate sweep without sending DMs") + + p_l_harvest = loop_sub.add_parser("harvest", parents=[common], help="Run immediate harvest cycle") + p_l_harvest.add_argument("--dry-run", action="store_true", help="Scrape without persisting watermarks") + + # Top-level domain aliases: strat and vars + p_strat_alias = subparsers.add_parser("strat", parents=[common], help="Shortcut for 'loop strat'") + strat_alias_sub = p_strat_alias.add_subparsers(dest="strat_action") + p_sa_show = strat_alias_sub.add_parser("show", parents=[common], help="Display active modulation strategy matrix") + p_sa_show.add_argument("type", nargs="?", default=None, help="Filter by input type") + + p_sa_set = strat_alias_sub.add_parser("set", parents=[common], help="Override loop modulation strategy") + p_sa_set.add_argument("type", help="Input type") + p_sa_set.add_argument("--subtype", default=None, help="Subtype") + p_sa_set.add_argument("--agent", choices=VALID_NODES, default=None, help="Target agent scope") + p_sa_set.add_argument("--track", choices=["true", "false", "actionable", "always", "never"], default=None, help="Track mode") + p_sa_set.add_argument("--priority", choices=["routine", "normal", "important", "critical"], default=None, help="Priority rank") + p_sa_set.add_argument("--timeout", type=int, default=None, help="Timeout in seconds before nudge") + p_sa_set.add_argument("--nudges", type=int, default=None, help="Max automatic nudges") + p_sa_set.add_argument("--escalate", default=None, help="Escalation target identity") + + p_sa_reset = strat_alias_sub.add_parser("reset", parents=[common], help="Reset loop strategy override") + p_sa_reset.add_argument("type", help="Input type") + p_sa_reset.add_argument("--subtype", default=None, help="Subtype") + p_sa_reset.add_argument("--agent", choices=VALID_NODES, default=None, help="Target agent scope") + + p_sa_eval = strat_alias_sub.add_parser("eval", parents=[common], help="Evaluate/simulate strategy resolution") + p_sa_eval.add_argument("type", help="Input type") + p_sa_eval.add_argument("--subtype", default=None, help="Subtype") + p_sa_eval.add_argument("--agent", choices=VALID_NODES, default=None, help="Target agent scope") + p_sa_eval.add_argument("--actionable", action="store_true", help="Actionable flag") + + p_vars_alias = subparsers.add_parser("vars", parents=[common], help="Shortcut for 'loop vars'") + vars_alias_sub = p_vars_alias.add_subparsers(dest="vars_action") + vars_alias_sub.add_parser("list", parents=[common], help="List all control variables") + + p_va_get = vars_alias_sub.add_parser("get", parents=[common], help="Inspect a specific control variable") + p_va_get.add_argument("name", help="Variable name") + + p_va_set = vars_alias_sub.add_parser("set", parents=[common], help="Set a control variable value") + p_va_set.add_argument("name", help="Variable name") + p_va_set.add_argument("value", help="New value") + + p_va_reset = vars_alias_sub.add_parser("reset", parents=[common], help="Reset a control variable to default") + p_va_reset.add_argument("name", help="Variable name") + + p_va_hist = vars_alias_sub.add_parser("history", parents=[common], help="Show variable modification audit history") + p_va_hist.add_argument("name", nargs="?", default=None, help="Variable name") + p_va_hist.add_argument("-n", "--limit", type=int, default=20, help="Number of records") + + p_va_rb = vars_alias_sub.add_parser("rollback", parents=[common], help="Roll back a variable to a previous value") + p_va_rb.add_argument("name", help="Variable name") + p_va_rb.add_argument("--revision", default=None, help="Revision step (int) or timestamp") + + return parser + +def main(): + parser = build_parser() + if len(sys.argv) == 1: + # Default behavior with no arguments: show fleet status + sys.argv.append("fleet") + sys.argv.append("status") + + args = parser.parse_args() + + # Route commands + if args.domain == "fleet": + act = getattr(args, "action", "status") or "status" + if act == "status": + cmd_fleet_status(args) + elif act == "watch": + cmd_fleet_watch(args) + elif act == "restart": + if not getattr(args, "node", None): + print("Error: Specify node to restart (e.g. super fleet restart muse)", file=sys.stderr) + sys.exit(1) + cmd_fleet_restart(args) + elif act == "cdp": + if not getattr(args, "node", None): + print("Error: Specify node (e.g. super fleet cdp muse)", file=sys.stderr) + sys.exit(1) + cmd_fleet_cdp(args) + elif args.domain == "dm": + act = getattr(args, "action", None) + if not act or act == "log": + cmd_dm_log(args) + elif act == "send": + cmd_dm_send(args) + elif act == "wo": + cmd_dm_wo(args) + elif act == "ack": + cmd_dm_ack(args) + elif act == "tail": + cmd_dm_tail(args) + elif act == "chat": + cmd_dm_chat(args) + elif act == "send-file": + cmd_dm_send_file(args) + elif act == "files": + cmd_dm_files(args) + elif act == "verify": + cmd_dm_verify(args) + else: + parser.print_help() + elif args.domain == "thread": + act = getattr(args, "action", None) + if act == "list": + cmd_thread_list(args) + elif act == "view": + cmd_thread_view(args) + else: + parser.print_help() + elif args.domain == "job": + act = getattr(args, "action", None) + if not act or act == "list": + cmd_job_list(args) + elif act == "show": + cmd_job_show(args) + elif act == "create": + cmd_job_create(args) + elif act == "enable": + cmd_job_enable(args) + elif act == "disable": + cmd_job_disable(args) + elif act == "delete": + cmd_job_delete(args) + elif act == "status": + cmd_job_status(args) + elif act == "run": + cmd_job_run(args) + elif act == "log": + cmd_job_log(args) + else: + parser.print_help() + elif args.domain == "web": + act = getattr(args, "action", None) + if not act or act == "health": + cmd_web_health(args) + elif act == "test-auth": + cmd_web_test_auth(args) + elif act == "sync": + cmd_web_sync(args) + else: + parser.print_help() + elif args.domain == "harvest": + act = getattr(args, "action", None) + if not act or act == "status": + cmd_harvest_status(args) + elif act == "run": + cmd_harvest_run(args) + elif act == "tail": + cmd_harvest_tail(args) + else: + parser.print_help() + elif args.domain == "followup": + act = getattr(args, "action", None) + if not act or act == "list": + cmd_followup_list(args) + elif act == "sweep": + cmd_followup_sweep(args) + elif act == "cancel": + cmd_followup_cancel(args) + else: + parser.print_help() + elif args.domain == "loop": + act = getattr(args, "action", None) + if not act or act == "status": + cmd_loop_status(args) + elif act == "health": + cmd_loop_health(args) + elif act == "breaks": + cmd_loop_breaks(args) + elif act == "strat": + cmd_loop_strat(args) + elif act == "vars": + cmd_loop_vars(args) + elif act == "close": + cmd_loop_close(args) + elif act == "nudge": + cmd_loop_nudge(args) + elif act == "sweep": + cmd_loop_sweep(args) + elif act == "harvest": + cmd_loop_harvest(args) + elif act == "remediate": + cmd_loop_remediate(args) + else: + parser.print_help() + elif args.domain == "strat": + cmd_loop_strat(args) + elif args.domain == "vars": + cmd_loop_vars(args) + else: + parser.print_help() + +if __name__ == "__main__": + main() diff --git a/job-sidechats.json b/job-sidechats.json new file mode 100644 index 0000000..89bcedc --- /dev/null +++ b/job-sidechats.json @@ -0,0 +1,16 @@ +{ + "heartbeat-opm": "5bd5b350-806c-4e04-939c-7ca7d1bd20df", + "646-pip-coord": { + "thread_uuid": "4466d0c1-7961-4cf3-b99d-1ab7c38484c2", + "agent": "pip" + }, + "646-opm-coord": { + "thread_uuid": "4139dd4e-96fe-4222-a99f-82a59a7b0eeb", + "agent": "opm" + }, + "test-auto-prov": { + "thread_uuid": "b96dd020-b429-4f5f-9e52-dbe8f805ac6a", + "agent": "646", + "created_at": "2026-10-04T16:20:33.919952+00:00" + } +} \ No newline at end of file