diff --git a/bin/box-ctl.py b/bin/box-ctl.py index 0d9f938..eb806bb 100755 --- a/bin/box-ctl.py +++ b/bin/box-ctl.py @@ -41,7 +41,9 @@ DM_LOG = NETVM_ROOT / "dm-log.jsonl" WATCHDOG_STATE = NETVM_ROOT / "main-chat-watchdog.state" NAME_RE = re.compile(r"^[a-z0-9-]{1,64}$") -VALID_AGENTS = {"muse", "pip", "646", "opm"} +VALID_AGENTS = {"muse", "pip", "646", "opm", "dev", "def"} +THREAD_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$") +THREAD_OPS = ("list", "pin", "unpin", "archive", "unarchive") # Default sidechat per agent for `box notify`. Mirrors super-cli.py # DEFAULT_AGENT_SIDECHATS (kept in sync manually; super-cli is the @@ -1216,6 +1218,14 @@ loop actions: loop-resolve [note] loop-remediate [--dry-run] +thread actions (muse-cli gateway; hyphenated aliases for `thread `): + thread-list --agent [--limit N] + thread-pin --agent --thread + thread-unpin --agent --thread + thread-archive --agent --thread --confirm + thread-unarchive --agent --thread + thread-rename --agent --thread --title + notify: notify <agent> <message> [--sidechat <name>] [--allow-main-chat] Send a DM to an agent's default sidechat (never main chat unless @@ -1224,7 +1234,74 @@ notify: policy: policy chat policy version, rule, and compliance summary policy check <agent> per-agent compliance detail - policy show print the full CHAT_POLICY.md""" + policy show print the full CHAT_POLICY.md + +thread actions (muse-cli gateway): + thread list <agent> [--limit N] + thread pin <agent> <thread-id> + thread unpin <agent> <thread-id> + thread archive <agent> <thread-id> --confirm + thread unarchive <agent> <thread-id>""" + + +def act_thread(op, agent, thread=None, title=None, limit=None, confirm=False): + """Thread bookkeeping via bin/muse-threads.py (hybrid gateway). + + ops: list | pin | unpin | archive | unarchive | rename + archive requires confirm=True (the --confirm flag; BatchMode SSH + cannot prompt, so the flag is the explicit confirmation). + """ + if op not in ("list", "pin", "unpin", "archive", "unarchive", "rename"): + fail("BAD_NAME", "usage: thread list|pin|unpin|archive|unarchive|rename <agent> [args]") + if agent not in VALID_AGENTS: + fail("BAD_NAME", f"agent must be one of {sorted(VALID_AGENTS)}") + if op == "list": + audit("thread-list-" + agent) + else: + if not thread or not THREAD_RE.match(thread): + fail("BAD_NAME", "thread id must match ^[A-Za-z0-9-]{1,64}$", + {"field": "thread", "value": thread}) + if op == "archive" and not confirm: + fail("CONFIRM_REQUIRED", + "archive is a visible side effect -- pass --confirm to proceed", + {"would_archive": {"agent": agent, "thread": thread}}) + if op == "rename" and not title: + fail("BAD_NAME", "rename requires a title") + audit("thread-" + op, f"{agent}/{thread}") + helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "muse-threads.py") + cmd = [sys.executable, helper, op, "--agent", agent] + if thread: + cmd += ["--thread", thread] + if title: + cmd += ["--title", title] + try: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + except subprocess.TimeoutExpired: + fail("GATEWAY_UNAVAILABLE", f"muse-threads.py timed out (op={op})") + except OSError as e: + fail("GATEWAY_UNAVAILABLE", f"cannot run muse-threads.py: {e}") + # Helper prints one JSON object; relay it, applying --limit for list + if p.returncode == 0: + raw = p.stdout.strip() + if limit is not None and op == "list" and raw: + try: + data = json.loads(raw) + if isinstance(data.get("threads"), list): + data["threads"] = data["threads"][:limit] + print(json.dumps(data)) + return + except ValueError: + pass + print(raw if raw else '{"ok": true}') + return + # Failure: try to extract the helper's JSON error, else wrap + try: + err = json.loads(p.stdout.strip() or "{}") + fail(err.get("code", "GATEWAY_ERROR"), err.get("error", "thread op failed"), + err.get("detail")) + except (ValueError, AttributeError): + fail("GATEWAY_ERROR", f"thread {op} failed", + (p.stderr.strip() or p.stdout.strip())[:300]) def main(argv): @@ -1374,6 +1451,30 @@ def main(argv): elif action == "loop-remediate": dry = "--dry-run" in rest act_loop_remediate(dry_run=dry) + elif action in ("thread-list", "thread-pin", "thread-unpin", + "thread-archive", "thread-unarchive", "thread-rename"): + op = action[len("thread-"):] + agent = None + thread = None + title = None + idx = 0 + while idx < len(rest): + if rest[idx] == "--agent" and idx + 1 < len(rest): + agent = rest[idx + 1] + idx += 2 + elif rest[idx] == "--thread" and idx + 1 < len(rest): + thread = rest[idx + 1] + idx += 2 + elif rest[idx] == "--title" and idx + 1 < len(rest): + title = rest[idx + 1] + idx += 2 + else: + idx += 1 + if not agent: + fail("BAD_NAME", f"usage: {action} --agent <agent> [--thread <id>] [--title <title>]") + if op in ("pin", "unpin", "archive", "unarchive", "rename") and not thread: + fail("BAD_NAME", f"usage: {action} --agent <agent> --thread <id> [--title <title>]") + act_thread(op, agent, thread, title) elif action == "notify": # notify <agent> <message> [--sidechat <name>] [--allow-main-chat] args = list(rest) @@ -1437,6 +1538,78 @@ def main(argv): if args: fail("BAD_ARGS", "usage: main-loop %s [--agent <name>]" % sub) act_main_loop(sub, agent) + elif action == "thread": + if not rest or rest[0] not in THREAD_OPS: + fail("BAD_NAME", "usage: thread list|pin|unpin|archive|unarchive <agent> [args]") + sub = rest[0] + args = rest[1:] + if not args: + fail("BAD_NAME", f"usage: thread {sub} <agent> [...]") + agent = args[0] + args = args[1:] + thread_id = None + limit = None + confirm = "--confirm" in args + args = [a for a in args if a != "--confirm"] + if confirm and sub != "archive": + fail("BAD_ARGS", "--confirm only applies to thread archive") + idx = 0 + while idx < len(args): + if args[idx] == "--limit" and idx + 1 < len(args): + try: + limit = int(args[idx + 1]) + except ValueError: + fail("BAD_LIMIT", "usage: thread list <agent> [--limit N]") + idx += 2 + elif thread_id is None and not args[idx].startswith("--"): + thread_id = args[idx] + idx += 1 + else: + fail("BAD_ARGS", f"usage: thread {sub} <agent> [...]") + if limit is not None and sub != "list": + fail("BAD_ARGS", "--limit only applies to thread list") + if sub in ("pin", "unpin", "archive", "unarchive") and not thread_id: + fail("BAD_NAME", f"usage: thread {sub} <agent> <thread-id> [--confirm]") + if sub == "list" and thread_id: + fail("BAD_ARGS", "usage: thread list <agent> [--limit N]") + act_thread(sub, agent, thread=thread_id, limit=limit, confirm=confirm) + elif action in ("thread-list", "thread-pin", "thread-unpin", + "thread-archive", "thread-unarchive", "thread-rename"): + op = action[len("thread-"):] + agent = None + thread_id = None + title = None + limit = None + confirm = "--confirm" in rest + args = [a for a in rest if a != "--confirm"] + if confirm and op != "archive": + fail("BAD_ARGS", "--confirm only applies to thread-archive") + idx = 0 + while idx < len(args): + if args[idx] == "--agent" and idx + 1 < len(args): + agent = args[idx + 1] + idx += 2 + elif args[idx] == "--thread" and idx + 1 < len(args): + thread_id = args[idx + 1] + idx += 2 + elif args[idx] == "--title" and idx + 1 < len(args): + title = args[idx + 1] + idx += 2 + elif args[idx] == "--limit" and idx + 1 < len(args): + try: + limit = int(args[idx + 1]) + except ValueError: + fail("BAD_LIMIT", "usage: thread-list --agent <agent> [--limit N]") + idx += 2 + else: + fail("BAD_ARGS", f"usage: {action} --agent <agent> [--thread <id>] [--title <t>] [--confirm] [--limit N]") + if not agent: + fail("BAD_NAME", f"usage: {action} --agent <agent> [...]") + if limit is not None and op != "list": + fail("BAD_ARGS", "--limit only applies to thread-list") + if op in ("pin", "unpin", "archive", "unarchive", "rename") and not thread_id: + fail("BAD_NAME", f"usage: {action} --agent <agent> --thread <id> [...]") + act_thread(op, agent, thread=thread_id, title=title, limit=limit, confirm=confirm) elif action == "dm-log": limit = 50 if rest: diff --git a/bin/dm.py b/bin/dm.py index 11b63db..ae54d03 100755 --- a/bin/dm.py +++ b/bin/dm.py @@ -53,9 +53,9 @@ except ImportError: 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"] +VALID_AGENTS = ["muse", "pip", "646", "opm", "dev", "def"] +VALID_SENDERS = ["muse", "pip", "646", "opm", "dev", "def", "super"] +VALID_RECIPIENTS = ["muse", "pip", "646", "opm", "dev", "def"] # ---- Canonical follow-up tags (2026-10-04, DEPLOY-DECISIONS.md) ---- # Tags declare follow-up policy at send time. They are metadata only: @@ -589,6 +589,11 @@ def dm_send(agent, target, message, verify=True, raw=False, log_event({"type": "sent", "id": msg_id, "agent": agent, "to": recipient, "target": target, "verified": True, "transport": "gateway", "tags": tags}) print(f"DM {msg_id} from {agent} to {recipient}/{target}: SENT and VERIFIED thread={thread_uuid} (gateway)") + # Follow-up record creation (same as the CDP path below): the + # gateway fast-path must not skip followup registration, or + # --expect-reply sends silently lose tracking. + if tags.get("reply:expected"): + _register_followup(msg_id, agent, recipient, target, tags) return msg_id except Exception as _e: log_event({"type": "gateway_fallback", "id": msg_id, "error": str(_e)[:100]})