diff --git a/.agents/skills/box/SKILL.md b/.agents/skills/box/SKILL.md index 5cccdf3..b5f7fdc 100644 --- a/.agents/skills/box/SKILL.md +++ b/.agents/skills/box/SKILL.md @@ -32,8 +32,8 @@ Add `--json` to any command for machine-readable output when parsing results in - `box lookup summary|fleet|threads|unread|approvals` — seamless one-shot lookups. - `box job list` / `box job log` — scheduled jobs and execution events. - `box harvest status` / `box followup list` — harvest watermarks / pending nudges. -- `box muse-choices on|off|status|logs|reconcile` — Muse TUI A/B/C auto-answer daemon switch, state, and per-pane logs. -- `box runtime list|send|launch` — Muse CLI tmux runtimes: live state + approval posture, send-keys input, auto-approved launches. +- `box muse-choices on|off|status|logs|reconcile` — Muse TUI auto-answer daemon switch, state, and per-pane logs (default on; `off` is the box-command opt-out). +- `box runtime list|send|launch|layout|spread` — Muse CLI tmux runtimes: live state + approval posture, send-keys input, auto-approved launches, pane-geometry layout + spread for squeezed panes. - `box tmux tally` / `box tmux auto [status|on|off|watch|once|logs|match]` — multi-socket Tmux worker tally, regex auto-approver daemon & guardrails. - `box onboard connects` / `box onboard-tui` — fleet & client onboarding inventory, CDP ports, OTP salvage & 4-surface TUI. - `box invite status|code |redeem ` / `box usage [--node N]` — invite codes and usage limits. diff --git a/bin/box-ctl.py b/bin/box-ctl.py index 6c0ac9f..9951f47 100755 --- a/bin/box-ctl.py +++ b/bin/box-ctl.py @@ -23,6 +23,7 @@ exit 0 on success, nonzero on failure. import json import os import re +import shutil import subprocess import sys import time @@ -44,6 +45,7 @@ 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}$") +VAR_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$") 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") @@ -949,16 +951,21 @@ def act_main_loop(sub, agent=None): out(ok, **data) -def act_watchdog_alerts(): +def act_watchdog_alerts(no_advance=False): """Check for new failed browser relaunches since the watermark. Runs bin/watchdog-alert-check.sh, which exits 0 (no new failures) or 1 (new failures printed to stdout) and maintains its own watermark at NETVM_ROOT/watchdog-alert-watermark.txt. + + no_advance=True passes --no-advance to the script: a peek-only read + that leaves the watermark untouched (for web-surface polling). + Default False preserves the classic CLI advance-on-read semantics. """ audit("watchdog-alerts") script = BIN / "watchdog-alert-check.sh" - r = subprocess.run([str(script)], capture_output=True, text=True, timeout=30) + cmd = [str(script)] + (["--no-advance"] if no_advance else []) + r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) failures = [l for l in (r.stdout or "").splitlines() if l.strip()] # Exit 0 = no new failures, exit 1 = new failures found. Any other # exit code is a script error. @@ -984,6 +991,8 @@ def act_fleet_status(): def act_approval_check(node=None): audit("approval-check", node) + if node is not None and node not in VALID_AGENTS: + fail("BAD_NODE", f"unknown node: {node}") import approvals nodes = [node] if node else list(VALID_AGENTS) res = approvals.check_fleet_approvals(nodes) @@ -1020,6 +1029,8 @@ def act_approval_deny(node, message=None, allow_main_chat=False): def act_approval_auto(node=None): audit("approval-auto", node) + if node is not None and node not in VALID_AGENTS: + fail("BAD_NODE", f"unknown node: {node}") import approvals nodes = [node] if node else list(VALID_AGENTS) res = approvals.auto_approve_fleet(nodes, caller="box-ctl") @@ -1027,6 +1038,70 @@ def act_approval_auto(node=None): out(True, **kw) +def act_tmux_tally(): + audit("tmux-tally") + try: + import tmux_auto_approver + tally = tmux_auto_approver.gather_tmux_tally() + out(True, **tmux_auto_approver.asdict(tally)) + except Exception as e: + fail("TMUX_ERROR", f"tmux tally failed: {e}") + + +def act_tmux_auto_status(): + audit("tmux-auto-status") + try: + import tmux_auto_approver + st = tmux_auto_approver.AutoApproverState.load() + out(True, **tmux_auto_approver.asdict(st)) + except Exception as e: + fail("TMUX_ERROR", f"tmux auto status failed: {e}") + + +def act_tmux_auto_toggle(enable=True, node=None, session=None): + audit("tmux-auto-toggle", f"{'on' if enable else 'off'}:{node or session or 'all'}") + if node and node not in VALID_AGENTS: + fail("BAD_NODE", f"unknown node: {node}") + try: + import tmux_auto_approver + st = tmux_auto_approver.AutoApproverState.load() + if node: + st.agents_enabled[node] = bool(enable) + elif session: + st.sessions_enabled[session] = bool(enable) + else: + st.global_enabled = bool(enable) + if enable: + for a in VALID_AGENTS: + st.agents_enabled[a] = True + st.save() + out(True, enabled=bool(enable), node=node, session=session, + global_enabled=st.global_enabled, agents_enabled=st.agents_enabled) + except Exception as e: + fail("TMUX_ERROR", f"tmux auto toggle failed: {e}") + + +def act_tmux_auto_once(dry_run=False): + audit("tmux-auto-once", f"dry_run={dry_run}") + try: + import tmux_auto_approver + runner = tmux_auto_approver.AutoApproverRunner(dry_run=dry_run) + actions = runner.run_once() + out(True, actions=actions, dry_run=dry_run, count=len(actions)) + except Exception as e: + fail("TMUX_ERROR", f"tmux auto run once failed: {e}") + + +def act_onboard_connects(): + audit("onboard-connects") + try: + import onboard_pipeline + connects = onboard_pipeline.get_all_connects() + out(True, connects=connects, count=len(connects)) + except Exception as e: + fail("ONBOARD_ERROR", f"onboard connects failed: {e}") + + def act_relay_health(): """Run relay-health-check.sh and return JSON results.""" audit("relay-health") @@ -1085,10 +1160,17 @@ def act_cdp_latency(): all_ok = bool(nodes) and all(n["ok"] for n in nodes) out(all_ok, nodes=nodes) -def act_chrome_errors(): - """Run chrome-error-scan.sh and return per-profile error counts as JSON.""" +def act_chrome_errors(no_advance=False): + """Run chrome-error-scan.sh and return per-profile error counts as JSON. + + no_advance=True passes --no-advance: a peek-only read that leaves the + watermark untouched (for web-surface polling). Default False preserves + the classic CLI advance-on-read semantics. + """ audit("chrome-errors") cmd = ["/bin/bash", str(BIN / "chrome-error-scan.sh"), "--json"] + if no_advance: + cmd.append("--no-advance") r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode == 0: try: @@ -1100,9 +1182,13 @@ def act_chrome_errors(): fail("SCAN_ERROR", "chrome-error-scan.sh failed", {"stderr": r.stderr}) -def act_dm_log(limit=50): - audit("dm-log", str(limit)) +def act_dm_log(limit=50, agent=None): + if agent is not None and agent not in VALID_AGENTS: + fail("BAD_NODE", f"unknown agent: {agent}") + audit("dm-log", f"{agent or 'all'}/{limit}") cmd = [sys.executable, str(BIN / "super-cli.py"), "dm", "log", "--json", "-n", str(limit)] + if agent: + cmd += ["--agent", agent] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode == 0: try: @@ -1115,6 +1201,192 @@ def act_dm_log(limit=50): fail("DM_LOG_ERROR", "failed to read dm log", {"stderr": r.stderr}) +def act_unread(agent=None): + if agent is not None and agent not in VALID_AGENTS: + fail("BAD_NODE", f"unknown agent: {agent}") + audit("unread", agent or "all") + cmd = [sys.executable, str(BIN / "super-cli.py"), "lookup", "unread", "--json"] + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode == 0: + try: + data = json.loads(r.stdout) + if agent: + data["nodes"] = [n for n in data.get("nodes", []) if n.get("node") == agent] + print(json.dumps(data)) + return + except Exception: + pass + fail("UNREAD_ERROR", "failed to collect unread counts", {"stderr": r.stderr}) + + +# --------------------------------------------------------------------------- +# No-SSH agent development: git visibility + test runs + ack. +# --------------------------------------------------------------------------- + +GIT_BIN = shutil.which("git") +GIT_MAX_DIFF = 64 * 1024 +GIT_MAX_STATUS = 200 +TESTS_MAX_OUTPUT = 32 * 1024 +TEST_MODULE_RE = re.compile(r"^tests\.[a-z0-9_]+$") + + +def _git_path(value): + """Validate a repo-relative path for git subcommands (no escapes).""" + if not value or not isinstance(value, str): + fail("BAD_NAME", "path must be a non-empty string") + if ".." in value or value.startswith("/") or \ + any(ord(c) < 32 or ord(c) == 127 for c in value): + fail("BAD_NAME", "path must be repo-relative without '..'") + try: + resolved = (NETVM_ROOT / value).resolve() + resolved.relative_to(NETVM_ROOT.resolve()) + except (OSError, ValueError): + fail("BAD_NAME", "path escapes the repository") + return value + + +def _run_git(args, timeout=30): + if GIT_BIN is None: + fail("GIT_ERROR", "git executable not found") + try: + return subprocess.run([GIT_BIN, "-C", str(NETVM_ROOT), *args], + capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + fail("GIT_ERROR", "git command timed out") + except OSError as e: + fail("GIT_ERROR", f"cannot run git: {e}") + + +def act_git_status(): + audit("git-status") + r = _run_git(["status", "--short", "--branch"]) + if r.returncode != 0: + fail("GIT_ERROR", "git status failed", {"stderr": (r.stderr or "")[:500]}) + lines = (r.stdout or "").splitlines() + branch = "unknown" + if lines and lines[0].startswith("## "): + branch = lines[0][3:].split("...")[0] or "unknown" + lines = lines[1:] + changes = [l for l in lines if l.strip()] + truncated = len(changes) > GIT_MAX_STATUS + out(True, branch=branch, changes=changes[:GIT_MAX_STATUS], truncated=truncated) + + +def act_git_diff(stat=False, path=None): + if path is not None: + path = _git_path(path) + audit("git-diff", f"{'stat' if stat else 'full'}:{path or 'all'}") + args = ["diff"] + if stat: + args.append("--stat") + if path: + args += ["--", path] + r = _run_git(args) + if r.returncode != 0: + fail("GIT_ERROR", "git diff failed", {"stderr": (r.stderr or "")[:500]}) + text = r.stdout or "" + truncated = len(text) > GIT_MAX_DIFF + out(True, stat=bool(stat), path=path, diff=text[:GIT_MAX_DIFF], + truncated=truncated) + + +def act_git_log(limit=10, path=None): + if path is not None: + path = _git_path(path) + try: + limit = int(limit) + except (TypeError, ValueError): + fail("BAD_LIMIT", "limit must be an integer") + if not 1 <= limit <= 50: + fail("BAD_LIMIT", "limit must be 1..50") + audit("git-log", f"{path or 'all'}/{limit}") + args = ["log", "--oneline", "-n", str(limit)] + if path: + args += ["--", path] + r = _run_git(args) + if r.returncode != 0: + fail("GIT_ERROR", "git log failed", {"stderr": (r.stderr or "")[:500]}) + commits = [] + for line in (r.stdout or "").splitlines(): + line = line.strip() + if not line: + continue + sha, _, subject = line.partition(" ") + commits.append({"sha": sha, "subject": subject}) + out(True, commits=commits) + + +def act_tests_run(module=None, filter=None): + if module is not None: + if not TEST_MODULE_RE.match(module): + fail("BAD_NAME", "module must match ^tests\\.[a-z0-9_]+$") + if not (NETVM_ROOT / "tests" / (module.split(".", 1)[1] + ".py")).is_file(): + fail("NOT_FOUND", f"unknown test module: {module}") + if filter is not None: + if not isinstance(filter, str) or not filter.strip() or len(filter) > 200: + fail("BAD_ARGS", "filter must be 1-200 chars") + if any(ord(c) < 32 or ord(c) == 127 for c in filter): + fail("BAD_ARGS", "filter contains control characters") + audit("tests-run", f"{module or 'all'}:{filter or '-'}") + if module: + argv = [sys.executable, "-m", "unittest", module] + else: + # No -t: tests/ has no __init__.py, so it is not importable as a + # package from top-level-dir '.'. Plain `discover -s tests` (the + # repo's own invocation) imports modules top-level and works. + argv = [sys.executable, "-m", "unittest", "discover", "-s", "tests"] + if filter: + argv += ["-k", filter] + # Hermetic import root: `python -m` prepends CWD to sys.path, but that + # is suppressed under PYTHONSAFEPATH/-P/isolated interpreters. Pin it. + env = dict(os.environ) + env["PYTHONPATH"] = str(NETVM_ROOT) + ( + os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "") + try: + r = subprocess.run(argv, capture_output=True, text=True, + timeout=600, cwd=str(NETVM_ROOT), env=env) + except subprocess.TimeoutExpired: + fail("TESTS_ERROR", "test run timed out after 600s") + except OSError as e: + fail("TESTS_ERROR", f"cannot run tests: {e}") + combined = (r.stdout or "") + (r.stderr or "") + truncated = len(combined) > TESTS_MAX_OUTPUT + out(r.returncode == 0, returncode=r.returncode, + output=combined[-TESTS_MAX_OUTPUT:], truncated=truncated, + module=module or "all") + + +def act_ack(ref_id, to, sender, sidechat=None, allow_main_chat=False): + if not ref_id or not DM_ID_RE.match(ref_id): + fail("BAD_NAME", "id must be 6-64 hex chars", + {"field": "id", "value": ref_id}) + if to not in VALID_AGENTS: + fail("BAD_NAME", f"to must be one of {sorted(VALID_AGENTS)}") + if sender not in VALID_AGENTS: + fail("BAD_NAME", f"sender must be one of {sorted(VALID_AGENTS)}") + # Sidechat-first policy (mirrors notify): default to the agent's + # sidechat, never main, unless explicitly overridden. + if allow_main_chat: + target = "main" + extra = ["--allow-main-chat"] + else: + target = sidechat if sidechat else NOTIFY_SIDECHATS.get(to) + if not target: + fail("BAD_NAME", f"no default sidechat for agent {to}; pass --sidechat ") + if sidechat and not _valid_sidechat_name(sidechat): + fail("BAD_NAME", "sidechat name must be 1-64 chars, no control characters") + extra = [] + message = f"[ACK:{ref_id}]" + r = run([sys.executable, str(DM_PY), "send", + "--agent", sender, "--to", to, "--target", target, + *extra, message], + timeout=120) + if r.returncode != 0: + fail("DISPATCH_FAILED", f"dm.py send failed: {(r.stderr or r.stdout).strip()[-500:]}") + audit("ack", f"{sender}->{to}:{ref_id}") + out(True, to=to, target=target, ack=ref_id, sent=True) + + def _policy_meta(): """Parse Version:/Date: from CHAT_POLICY.md.""" @@ -1549,11 +1821,19 @@ def act_ssh_info(name): out(True, **agent_md.get_ssh_info(name)) +MD_MAX_READ = 64 * 1024 +MD_MAX_DIFF = 64 * 1024 +MD_MAX_LIST = 200 + + def act_md_audit(accounts=None): """Audit markdown files and operational drive across fleet agents.""" import agent_md audit("md-audit", ",".join(accounts or [])) - res = agent_md.audit_agents(accounts=accounts) + try: + res = agent_md.audit_agents(accounts=accounts) + except agent_md.MDValidationError as e: + fail("BAD_NAME", str(e)) out(True, agents=res) @@ -1561,31 +1841,142 @@ def act_md_list(account, path=""): """List container files via Hatch.""" import agent_md audit("md-list", f"{account}:{path}") - entries = agent_md.list_files(account, path=path) - out(True, account=account, path=path, entries=entries) + try: + entries = agent_md.list_files(account, path=path) + except agent_md.MDValidationError as e: + fail("BAD_NAME", str(e)) + except FileNotFoundError as e: + fail("NOT_FOUND", str(e)) + except Exception as e: + fail("GATEWAY_ERROR", f"md-list failed: {e}") + truncated = len(entries) > MD_MAX_LIST + out(True, account=account, path=path, entries=entries[:MD_MAX_LIST], + truncated=truncated) def act_md_read(account, filename): """Read a markdown file from the agent container via Hatch.""" import agent_md audit("md-read", f"{account}:{filename}") - res = agent_md.read_md(account, filename) - out(res.pop("ok", True), **res) + try: + res = agent_md.read_md(account, filename) + except agent_md.MDValidationError as e: + fail("BAD_NAME", str(e)) + except FileNotFoundError as e: + fail("NOT_FOUND", str(e)) + except Exception as e: + fail("GATEWAY_ERROR", f"md-read failed: {e}") + ok = res.pop("ok", True) + text = res.get("text", "") + truncated = len(text) > MD_MAX_READ + if truncated: + res["text"] = text[:MD_MAX_READ] + out(ok, truncated=truncated, **res) def act_md_write(account, filename, content, overwrite=True, append=False): """Write content to an agent's container file via Hatch.""" import agent_md audit("md-write", f"{account}:{filename}") - res = agent_md.write_md(account, filename, content, overwrite=overwrite, append=append) + try: + res = agent_md.write_md(account, filename, content, overwrite=overwrite, append=append) + except agent_md.MDValidationError as e: + fail("BAD_NAME", str(e)) + except FileNotFoundError as e: + fail("NOT_FOUND", str(e)) + except Exception as e: + fail("GATEWAY_ERROR", f"md-write failed: {e}") out(res.pop("ok", True), **res) def act_md_diff(account, filename): """Diff remote container file against local shared/operators template.""" import agent_md - res = agent_md.diff_md(account, filename) - out(res.pop("ok", True), **res) + audit("md-diff", f"{account}:{filename}") + try: + res = agent_md.diff_md(account, filename) + except agent_md.MDValidationError as e: + fail("BAD_NAME", str(e)) + except FileNotFoundError as e: + fail("NOT_FOUND", str(e)) + except Exception as e: + fail("GATEWAY_ERROR", f"md-diff failed: {e}") + ok = res.pop("ok", True) + diff = res.get("diff", "") + truncated = len(diff) > MD_MAX_DIFF + if truncated: + res["diff"] = diff[:MD_MAX_DIFF] + out(ok, truncated=truncated, **res) + + +def _md_amend_parse(args, usage): + """Parse amend argv: filename + content/--content/--file/--stdin + flags.""" + if not args: + fail("BAD_ARGS", usage) + filename = args[0] + content = None + author = "operator" + reason = "" + idx = 1 + while idx < len(args): + if args[idx] == "--file" and idx + 1 < len(args): + content = Path(args[idx + 1]).read_text(encoding="utf-8") + idx += 2 + elif args[idx] == "--content" and idx + 1 < len(args): + content = args[idx + 1] + idx += 2 + elif args[idx] == "--stdin": + content = sys.stdin.read() + idx += 1 + elif args[idx] == "--author" and idx + 1 < len(args): + author = args[idx + 1] + idx += 2 + elif args[idx] == "--reason" and idx + 1 < len(args): + reason = args[idx + 1] + idx += 2 + elif content is None and not args[idx].startswith("--"): + content = args[idx] + idx += 1 + else: + idx += 1 + if content is None: + fail("BAD_ARGS", usage) + return filename, content, author, reason + + +def _md_append_parse(args, usage): + """Parse append argv: filename + text/--content/--file/--stdin + flags.""" + if not args: + fail("BAD_ARGS", usage) + filename = args[0] + text = None + author = "operator" + section = None + idx = 1 + while idx < len(args): + if args[idx] == "--file" and idx + 1 < len(args): + text = Path(args[idx + 1]).read_text(encoding="utf-8") + idx += 2 + elif args[idx] == "--content" and idx + 1 < len(args): + text = args[idx + 1] + idx += 2 + elif args[idx] == "--stdin": + text = sys.stdin.read() + idx += 1 + elif args[idx] == "--author" and idx + 1 < len(args): + author = args[idx + 1] + idx += 2 + elif args[idx] == "--section" and idx + 1 < len(args): + section = args[idx + 1] + idx += 2 + elif text is None and not args[idx].startswith("--"): + text = args[idx] + idx += 1 + else: + idx += 1 + if text is None: + fail("BAD_ARGS", usage) + return filename, text, author, section def act_md_amend(filename, content, author="operator", reason=""): @@ -1595,6 +1986,8 @@ def act_md_amend(filename, content, author="operator", reason=""): try: res = agent_md.amend_md(filename, content, author=author, reason=reason) out(res.pop("ok", True), **res) + except agent_md.MDValidationError as e: + fail("BAD_NAME", str(e)) except Exception as e: fail("AMEND_FAILED", str(e)) @@ -1606,6 +1999,8 @@ def act_md_append(filename, text, author="operator", section=None): try: res = agent_md.append_md(filename, text, author=author, section=section) out(res.pop("ok", True), **res) + except agent_md.MDValidationError as e: + fail("BAD_NAME", str(e)) except Exception as e: fail("APPEND_FAILED", str(e)) @@ -1617,6 +2012,8 @@ def act_md_pull(account, filename): try: res = agent_md.pull_md(account, filename) out(res.pop("ok", True), **res) + except agent_md.MDValidationError as e: + fail("BAD_NAME", str(e)) except Exception as e: fail("PULL_FAILED", str(e)) @@ -1625,7 +2022,14 @@ def act_md_inject_drive(account, force=False): """Inject high-drive operational templates into an agent container via Hatch.""" import agent_md audit("md-inject-drive", account) - res = agent_md.inject_drive(account, force=force) + try: + res = agent_md.inject_drive(account, force=force) + except agent_md.MDValidationError as e: + fail("BAD_NAME", str(e)) + except FileNotFoundError as e: + fail("NOT_FOUND", str(e)) + except Exception as e: + fail("GATEWAY_ERROR", f"md-inject-drive failed: {e}") out(res.pop("ok", True), **res) @@ -1755,6 +2159,8 @@ thread actions (muse-cli gateway; hyphenated aliases for `thread `): notify: notify [--sidechat ] [--allow-main-chat] + ack --to --sender [--sidechat ] [--allow-main-chat] + acknowledge a DM or work order (sidechat-first) Send a DM to an agent's default sidechat (never main chat unless --allow-main-chat is passed explicitly). @@ -1778,7 +2184,9 @@ swarm actions (agent swarms; bl owns state, operator agents do the spawning): swarm-report (result JSON on stdin) swarm-results swarm-kill --confirm - (space-separated aliases: swarm spawn|list|status|attach|report|results|kill) + swarm-prune [--stale-hours H] [--confirm] + archive stale terminal swarms (previews count without --confirm) + (space-separated aliases: swarm spawn|list|status|attach|report|results|kill|prune) quality: quality-check run box-ctl self-diagnostics (validators, output contract, audit path, atomic writes) @@ -1787,7 +2195,69 @@ quality: (space-separated alias: quality check|validate) dm-log: - dm-log [limit] recent DM send log""" + dm-log [limit] [--agent ] + recent DM send log (agent-scoped when given) + unread [--agent ] fleet unread/activity counts (read-only) + +dev (no-SSH agent development): + git-status git status --short --branch (read-only) + git-diff [--stat] [--path

] + git diff, capped at 64KB (read-only) + git-log [--limit N] [--path

] + recent commits as sha/subject (read-only) + tests-run [tests.] [--filter ] + run repo unit tests (full suite when omitted; + --filter is unittest -k) + +md actions (agent .md files via Hatch; space-separated aliases: md audit|list|read|write|diff|amend|append|pull|inject-drive|sync-all): + md-audit [accounts...] fleet drive audit (read-only) + md-list [path] list container files, capped at 200 (read-only) + md-read + read container file, capped at 64KB (read-only) + md-diff + diff against shared template, capped at 64KB (read-only) + md-pull + pull canonical template into container + md-inject-drive [--force] + inject high-drive templates into one agent + md-sync-all [--force] inject high-drive templates across all agents + md-amend (|--content t|--file p|--stdin) + rewrite shared template (validated, git-committed) + [--author name] [--reason why] + md-append (|--content t|--file p|--stdin) + append to shared template (git-committed) + [--author name] [--section header] + md-write + raw container write (SSH-only, no HTTPS op) + +approval actions (fleet browser approvals via CDP): + approval-check [node] pending approvals across fleet or one node (read-only) + approval-list [node] (alias for approval-check) + approval-allow [--always] [--force] [--message TEXT] [--allow-main-chat] + allow the node's active approval + approval-approve [...] (alias for approval-allow) + approval-deny [--message TEXT] [--allow-main-chat] + deny the node's active approval + approval-auto [node] auto-allow TRUSTED (non-key) prompts + +tmux & worker actions (multi-socket tally & regex auto-approvals): + tmux-tally tally all tmux sessions, workers, and panes across fleet sockets (read-only) + tmux-auto-status runtime status of tmux auto-approvals & rule inventory (read-only) + tmux-auto-toggle [--enable|--disable] [--node NODE] [--session SESSION] + toggle tmux auto-approvals globally, per-agent, or per-session + tmux-auto-once [--dry-run] one-shot scan & auto-approve terminal prompts + (space-separated alias: tmux tally|auto|status|toggle|once) + +onboard actions: + onboard-connects consolidated active fleet and client onboard connects (read-only) + (space-separated alias: onboard connects) + +ssh actions (space-separated alias: ssh mint|list|show|ports|info): + ssh-mint [--force] mint a new SSH key + ssh-list list minted keys + ssh-show show key detail + ssh-ports tunnel port inventory + ssh-info [name] connection coordinates""" def act_thread(op, agent, thread=None, title=None, limit=None, confirm=False): @@ -2171,15 +2641,20 @@ def act_swarm_kill(sid, confirm=False): QUALITY_VERSION = "1" KNOWN_ERROR_CODES = frozenset([ - "ALREADY_EXISTS", "AUDIT_UNAVAILABLE", "BAD_ARGS", "BAD_COUNT", - "BAD_LIMIT", "BAD_NAME", "BAD_SLOT", "BAD_TASK", "CONFIRM_REQUIRED", + "ALREADY_EXISTS", "AMEND_FAILED", "APPEND_FAILED", "APPROVAL_FAILED", + "AUDIT_UNAVAILABLE", "BAD_ARGS", "BAD_COUNT", + "BAD_LIMIT", "BAD_NAME", "BAD_NODE", "BAD_SLOT", "BAD_TASK", + "CONFIRM_REQUIRED", "DISPATCH_FAILED", "DM_LOG_ERROR", "FLEET_ERROR", "GATEWAY_ERROR", "GATEWAY_UNAVAILABLE", "INVALID_JOB", "INVALID_RESULT", - "INVALID_SCHEDULE", "LATENCY_TIMEOUT", "LOG_FAILED", "LOOP_ERROR", + "INVALID_SCHEDULE", "IO_ERROR", "KEYGEN_FAILED", "KEY_EXISTS", + "LATENCY_TIMEOUT", "LOG_FAILED", "LOOP_ERROR", "LOOP_TIMEOUT", "NAME_MISMATCH", "NOT_FOUND", "POLICY_ERROR", + "PULL_FAILED", "SCAN_ERROR", "STRAT_ERROR", "SWARM_CLOSED", "SWARM_NOT_FOUND", "SWARM_SLOT_BUSY", "SWARM_SLOT_CLOSED", "TIMER_CREATE_FAILED", - "TIMER_STILL_ACTIVE", "VARS_ERROR", "WATCHDOG_CHECK_ERROR", + "TIMER_STILL_ACTIVE", "UNREAD_ERROR", "VARS_ERROR", "WATCHDOG_CHECK_ERROR", + "GIT_ERROR", "TESTS_ERROR", "TMUX_ERROR", "ONBOARD_ERROR", ]) # Read-only / dry-run actions: safe to retry. @@ -2189,14 +2664,27 @@ IDEMPOTENT_ACTIONS = frozenset([ "job-list", "job-get", "job-status", "job-next", "vars-list", "vars-get", "vars-history", "strat-list", "strat-get", "loop-status", "loop-health", "loop-breaks", "policy", "policy-check", "policy-show", - "thread-list", "dm-log", "swarm-list", "swarm-status", "swarm-results", + "thread-list", "dm-log", "unread", "swarm-list", "swarm-status", "swarm-results", "quality-check", "quality-validate", "main-loop", + "git-status", "git-diff", "git-log", + "md-audit", "md-list", "md-read", "md-diff", + "approval-check", "approval-list", + "tmux-tally", "tmux-auto-status", "onboard-connects", ]) JOB_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,127}$") SWARM_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") AGENT_REF_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$") DM_ID_RE = re.compile(r"^[0-9a-fA-F]{6,64}$") +# Mirrors agent_md.py validation (single-copy lives there; these keep +# dry-run validation in sync without importing the gateway module). +MD_ACCOUNT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$") +MD_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$") +MD_SUBPATH_RE = re.compile(r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$") +MD_TEMPLATE_FILES = frozenset([ + "SOUL.md", "PROACTIVE_PREFERENCES.md", "HEARTBEAT.md", "AGENTS.md", + "MEMORY.md", "USER.md", "TOOLS.md", "IDENTITY.md", +]) def _qcheck(name, ok, code=None, reason=None): @@ -2215,6 +2703,15 @@ def qv_name(value, field="name"): return _qcheck(field, True) +def qv_var_name(value, field="name"): + # Variable names (loop_health_threshold, ...) allow underscores and + # dots, unlike job names. Mirrors exec-constrained.py NAME_RE. + if not value or not VAR_NAME_RE.match(value): + return _qcheck(field, False, "BAD_NAME", + "must match ^[A-Za-z0-9_.-]{1,64}$") + return _qcheck(field, True) + + def qv_agent(value): if value not in VALID_AGENTS: return _qcheck("agent", False, "BAD_NAME", @@ -2258,6 +2755,25 @@ def qv_dm_id(value): return _qcheck("dm_id", True) +def qv_relpath(value, field="path"): + if not value or not isinstance(value, str): + return _qcheck(field, False, "BAD_NAME", "path must be non-empty") + if ".." in value or value.startswith("/"): + return _qcheck(field, False, "BAD_NAME", + "path must be repo-relative without '..'") + if any(ord(c) < 32 or ord(c) == 127 for c in value): + return _qcheck(field, False, "BAD_NAME", + "path contains control characters") + return _qcheck(field, True) + + +def qv_test_module(value): + if not value or not TEST_MODULE_RE.match(value): + return _qcheck("module", False, "BAD_NAME", + "must match ^tests\\.[a-z0-9_]+$") + return _qcheck("module", True) + + def qv_int_range(value, lo, hi, field, code): try: n = int(str(value)) @@ -2286,6 +2802,36 @@ def qv_nonempty(value, field): return _qcheck(field, True) +def qv_md_account(value): + if not value or not MD_ACCOUNT_RE.match(value): + return _qcheck("account", False, "BAD_NAME", + "must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$") + return _qcheck("account", True) + + +def qv_md_file(value): + if not value or value in (".", "..") or not MD_FILENAME_RE.match(value): + return _qcheck("filename", False, "BAD_NAME", + "must be a plain basename (no directories)") + return _qcheck("filename", True) + + +def qv_md_template(value): + if value not in MD_TEMPLATE_FILES: + return _qcheck("filename", False, "BAD_NAME", + "must be one of %s" % sorted(MD_TEMPLATE_FILES)) + return _qcheck("filename", True) + + +def qv_md_subpath(value): + if value in (None, ""): + return _qcheck("path", True) + if not MD_SUBPATH_RE.match(value) or ".." in value.split("/"): + return _qcheck("path", False, "BAD_NAME", + "path must be a subdir without '..'") + return _qcheck("path", True) + + def qv_title(value): c = qv_nonempty(value, "title") if not c["ok"]: @@ -2726,7 +3272,7 @@ def _qv_args(action, rest): if len(a) != 1: return [_qcheck("argv", False, "BAD_ARGS", "usage: %s " % action)] - return [qv_name(a[0])] + return [qv_var_name(a[0])] if action == "vars-list": if a: return [_qcheck("argv", False, "BAD_ARGS", "usage: vars-list")] @@ -2735,12 +3281,12 @@ def _qv_args(action, rest): if len(a) != 2: return [_qcheck("argv", False, "BAD_ARGS", "usage: vars-set ")] - return [qv_name(a[0]), _qcheck("value", True)] + return [qv_var_name(a[0]), _qcheck("value", True)] if action == "vars-history": checks = [] b = list(a) if b and not b[0].isdigit(): - checks.append(qv_name(b.pop(0), "name")) + checks.append(qv_var_name(b.pop(0), "name")) if b: if len(b) != 1: return [_qcheck("argv", False, "BAD_ARGS", @@ -2751,7 +3297,7 @@ def _qv_args(action, rest): if not (1 <= len(a) <= 2): return [_qcheck("argv", False, "BAD_ARGS", "usage: vars-rollback [revision]")] - checks = [qv_name(a[0])] + checks = [qv_var_name(a[0])] if len(a) == 2: checks.append(qv_nonempty(a[1], "revision")) return checks @@ -2860,9 +3406,14 @@ def _qv_args(action, rest): "usage: policy [check |show]")] if action in ("fleet-status", "watchdog-alerts", "relay-health", "cdp-latency", "chrome-errors", "identity-audit"): - if a: + # watchdog-alerts / chrome-errors accept an optional --no-advance + # (peek-only read for web-surface polling; leaves the watermark). + extra_ok = (["--no-advance"] if action in ("watchdog-alerts", + "chrome-errors") else []) + if a and a != extra_ok: + usage = "%s [--no-advance]" % action if extra_ok else "%s (no arguments)" % action return [_qcheck("argv", False, "BAD_ARGS", - "usage: %s (no arguments)" % action)] + "usage: %s" % usage)] return [_qcheck("argv", True)] if action == "main-loop": if not a or a[0] not in ("check", "status", "enable", "disable"): @@ -2877,17 +3428,220 @@ def _qv_args(action, rest): "--agent requires a value")) return checks if action == "dm-log": - if len(a) > 1: - return [_qcheck("argv", False, "BAD_ARGS", "usage: dm-log [limit]")] - if not a: - return [_qcheck("argv", True)] - return [qv_limit(a[0])] + has_agent, agent, b = _qv_flag(a, "--agent", takes_value=True) + if len(b) > 1: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: dm-log [limit] [--agent ]")] + checks = [qv_limit(b[0])] if b else [] + if has_agent: + checks.append(qv_agent(agent) if agent is not None + else _qcheck("agent", False, "BAD_ARGS", + "--agent requires a value")) + return checks or [_qcheck("argv", True)] + if action == "unread": + has_agent, agent, b = _qv_flag(a, "--agent", takes_value=True) + checks = _qv_no_unknown(b, "unread [--agent ]") + if has_agent: + checks.append(qv_agent(agent) if agent is not None + else _qcheck("agent", False, "BAD_ARGS", + "--agent requires a value")) + return checks or [_qcheck("argv", True)] + if action == "git-status": + if a: + return [_qcheck("argv", False, "BAD_ARGS", "usage: git-status")] + return [_qcheck("argv", True)] + if action == "git-diff": + has_stat, _, b = _qv_flag(a, "--stat") + has_path, path, b = _qv_flag(b, "--path", takes_value=True) + checks = _qv_no_unknown(b, "git-diff [--stat] [--path ]") + if has_path: + checks.append(qv_relpath(path) if path is not None + else _qcheck("path", False, "BAD_ARGS", + "--path requires a value")) + return checks or [_qcheck("argv", True)] + if action == "git-log": + has_limit, limit, b = _qv_flag(a, "--limit", takes_value=True) + has_path, path, b = _qv_flag(b, "--path", takes_value=True) + checks = _qv_no_unknown(b, "git-log [--limit N] [--path ]") + if has_limit: + checks.append(qv_int_range(limit, 1, 50, "limit", "BAD_LIMIT") + if limit is not None + else _qcheck("limit", False, "BAD_ARGS", + "--limit requires a value")) + if has_path: + checks.append(qv_relpath(path) if path is not None + else _qcheck("path", False, "BAD_ARGS", + "--path requires a value")) + return checks or [_qcheck("argv", True)] + if action == "tests-run": + has_filter, filt, b = _qv_flag(a, "--filter", takes_value=True) + if len(b) > 1: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: tests-run [tests.] [--filter ]")] + checks = [qv_test_module(b[0])] if b else [] + if has_filter: + if filt is None: + checks.append(_qcheck("filter", False, "BAD_ARGS", + "--filter requires a value")) + elif not filt.strip() or len(filt) > 200: + checks.append(_qcheck("filter", False, "BAD_ARGS", + "filter must be 1-200 chars")) + elif any(ord(c) < 32 or ord(c) == 127 for c in filt): + checks.append(_qcheck("filter", False, "BAD_ARGS", + "filter contains control characters")) + else: + checks.append(_qcheck("filter", True)) + return checks or [_qcheck("argv", True)] + if action == "ack": + _, _, b = _qv_flag(a, "--allow-main-chat") + has_to, to, b = _qv_flag(b, "--to", takes_value=True) + has_sender, sender, b = _qv_flag(b, "--sender", takes_value=True) + has_sc, sc_val, b = _qv_flag(b, "--sidechat", takes_value=True) + if len(b) != 1: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: ack --to --sender " + "[--sidechat ] [--allow-main-chat]")] + checks = [qv_dm_id(b[0])] + if not has_to or to is None: + checks.append(_qcheck("to", False, "BAD_ARGS", + "--to is required")) + else: + checks.append(qv_agent(to)) + if not has_sender or sender is None: + checks.append(_qcheck("sender", False, "BAD_ARGS", + "--sender is required")) + else: + checks.append(qv_agent(sender)) + if has_sc: + checks.append(qv_nonempty(sc_val, "sidechat") if sc_val is not None + else _qcheck("sidechat", False, "BAD_ARGS", + "--sidechat requires a value")) + return checks # -- quality (self) ---------------------------------------------------- if action == "quality-check": if a: return [_qcheck("argv", False, "BAD_ARGS", "usage: quality-check")] return [_qcheck("argv", True)] + # -- md -------------------------------------------------------------- + if action == "md-audit": + if not a: + return [_qcheck("argv", True)] + return [qv_md_account(x) for x in a] + if action == "md-list": + if len(a) not in (1, 2): + return [_qcheck("argv", False, "BAD_ARGS", + "usage: md-list [path]")] + checks = [qv_md_account(a[0])] + if len(a) == 2: + checks.append(qv_md_subpath(a[1])) + return checks + if action == "md-read": + if len(a) != 2: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: md-read ")] + return [qv_md_account(a[0]), qv_md_file(a[1])] + if action == "md-diff": + if len(a) != 2: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: md-diff ")] + return [qv_md_account(a[0]), qv_md_template(a[1])] + if action == "md-pull": + if len(a) != 2: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: md-pull ")] + return [qv_md_account(a[0]), qv_md_template(a[1])] + if action == "md-inject-drive": + _, _, b = _qv_flag(a, "--force") + if len(b) != 1: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: md-inject-drive [--force]")] + return [qv_md_account(b[0])] + if action == "md-sync-all": + _, _, b = _qv_flag(a, "--force") + if b: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: md-sync-all [--force]")] + return [_qcheck("argv", True)] + if action == "md-amend": + _, _, b = _qv_flag(a, "--stdin") + _, author, b = _qv_flag(b, "--author", takes_value=True) + _, reason, b = _qv_flag(b, "--reason", takes_value=True) + _, content, b = _qv_flag(b, "--content", takes_value=True) + _, file, b = _qv_flag(b, "--file", takes_value=True) + if len(b) not in (1, 2): + return [_qcheck("argv", False, "BAD_ARGS", + "usage: md-amend [] [--stdin] " + "[--content t] [--file p] [--author n] [--reason r]")] + checks = [qv_md_template(b[0])] + if author is not None: + checks.append(qv_nonempty(author, "author")) + return checks + [_qcheck("stdin", True, reason="content may come from " + "stdin/--file/--content at execution; not " + "consumed by dry-run")] + if action == "md-append": + _, _, b = _qv_flag(a, "--stdin") + _, author, b = _qv_flag(b, "--author", takes_value=True) + _, section, b = _qv_flag(b, "--section", takes_value=True) + _, content, b = _qv_flag(b, "--content", takes_value=True) + _, file, b = _qv_flag(b, "--file", takes_value=True) + if len(b) not in (1, 2): + return [_qcheck("argv", False, "BAD_ARGS", + "usage: md-append [] [--stdin] " + "[--content t] [--file p] [--author n] [--section s]")] + checks = [qv_md_template(b[0])] + if author is not None: + checks.append(qv_nonempty(author, "author")) + if section is not None: + checks.append(qv_nonempty(section, "section")) + return checks + [_qcheck("stdin", True, reason="text may come from " + "stdin/--file/--content at execution; not " + "consumed by dry-run")] + if action == "md-write": + if len(a) != 3: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: md-write ")] + return [qv_md_account(a[0]), qv_md_file(a[1]), + qv_nonempty(a[2], "content")] + # -- approval -------------------------------------------------------- + if action in ("approval-check", "approval-list"): + if len(a) > 1: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: %s [node]" % action)] + if not a: + return [_qcheck("argv", True)] + return [qv_agent(a[0])] + if action in ("approval-allow", "approval-approve"): + _, _, b = _qv_flag(a, "--always") + _, _, b = _qv_flag(b, "--force") + _, _, b = _qv_flag(b, "--allow-main-chat") + _, message, b = _qv_flag(b, "--message", takes_value=True) + if len(b) != 1: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: %s [--always] [--force] [--message TEXT] " + "[--allow-main-chat]" % action)] + checks = [qv_agent(b[0])] + if message is not None: + checks.append(qv_nonempty(message, "message")) + return checks + if action == "approval-deny": + _, _, b = _qv_flag(a, "--allow-main-chat") + _, message, b = _qv_flag(b, "--message", takes_value=True) + if len(b) != 1: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: approval-deny [--message TEXT] " + "[--allow-main-chat]")] + checks = [qv_agent(b[0])] + if message is not None: + checks.append(qv_nonempty(message, "message")) + return checks + if action == "approval-auto": + if len(a) > 1: + return [_qcheck("argv", False, "BAD_ARGS", + "usage: approval-auto [node]")] + if not a: + return [_qcheck("argv", True)] + return [qv_agent(a[0])] if action == "quality-validate": if not a: return [_qcheck("argv", False, "BAD_ARGS", @@ -3141,7 +3895,8 @@ def main(argv): act_ssh_ports() elif sub == "info": act_ssh_info(args[0] if args else None) - elif action in ("md", "md-audit", "md-list", "md-read", "md-write", "md-diff", "md-inject-drive", "md-sync-all"): + elif action in ("md", "md-audit", "md-list", "md-read", "md-write", "md-diff", "md-inject-drive", "md-sync-all", + "md-amend", "md-append", "md-pull"): if action == "md-audit": act_md_audit(accounts=rest or None) elif action == "md-list": @@ -3166,6 +3921,18 @@ def main(argv): act_md_inject_drive(rest[0], force=("--force" in rest[1:])) elif action == "md-sync-all": act_md_sync_all(force=("--force" in rest)) + elif action == "md-amend": + filename, content, author, reason = _md_amend_parse( + rest, "usage: md-amend (|--content t|--file p|--stdin) [--author name] [--reason why]") + act_md_amend(filename, content, author=author, reason=reason) + elif action == "md-append": + filename, text, author, section = _md_append_parse( + rest, "usage: md-append (|--content t|--file p|--stdin) [--author name] [--section header]") + act_md_append(filename, text, author=author, section=section) + elif action == "md-pull": + if len(rest) != 2: + fail("BAD_ARGS", "usage: md-pull ") + act_md_pull(rest[0], rest[1]) elif action == "md": if not rest: fail("BAD_NAME", "usage: md audit|list|read|write|diff|inject-drive|sync-all [...]") @@ -3209,51 +3976,12 @@ def main(argv): elif sub == "sync-all": act_md_sync_all(force=("--force" in args)) elif sub == "amend": - if len(args) < 2: - fail("BAD_ARGS", "usage: md amend [--content text | --file path] [--author name] [--reason why]") - filename = args[0] - content = None - author = "operator" - reason = "" - idx = 1 - while idx < len(args): - if args[idx] == "--file" and idx + 1 < len(args): - content = Path(args[idx + 1]).read_text(encoding="utf-8") - idx += 2 - elif args[idx] == "--content" and idx + 1 < len(args): - content = args[idx + 1] - idx += 2 - elif args[idx] == "--author" and idx + 1 < len(args): - author = args[idx + 1] - idx += 2 - elif args[idx] == "--reason" and idx + 1 < len(args): - reason = args[idx + 1] - idx += 2 - elif content is None and not args[idx].startswith("--"): - content = args[idx] - idx += 1 - else: - idx += 1 - if content is None: - fail("BAD_ARGS", "usage: md amend [--author name] [--reason why]") + filename, content, author, reason = _md_amend_parse( + args, "usage: md amend (|--content t|--file p|--stdin) [--author name] [--reason why]") act_md_amend(filename, content, author=author, reason=reason) elif sub == "append": - if len(args) < 2: - fail("BAD_ARGS", "usage: md append [--author name] [--section header]") - filename = args[0] - text = args[1] - author = "operator" - section = None - idx = 2 - while idx < len(args): - if args[idx] == "--author" and idx + 1 < len(args): - author = args[idx + 1] - idx += 2 - elif args[idx] == "--section" and idx + 1 < len(args): - section = args[idx + 1] - idx += 2 - else: - idx += 1 + filename, text, author, section = _md_append_parse( + args, "usage: md append (|--content t|--file p|--stdin) [--author name] [--section header]") act_md_append(filename, text, author=author, section=section) elif sub == "pull": if len(args) < 2: @@ -3304,9 +4032,9 @@ def main(argv): elif action == "fleet-status": act_fleet_status() elif action == "watchdog-alerts": - if rest: - fail("BAD_NAME", "usage: watchdog-alerts") - act_watchdog_alerts() + if rest and rest != ["--no-advance"]: + fail("BAD_NAME", "usage: watchdog-alerts [--no-advance]") + act_watchdog_alerts(no_advance=rest == ["--no-advance"]) elif action == "relay-health": act_relay_health() elif action == "identity-audit": @@ -3316,9 +4044,9 @@ def main(argv): elif action == "cdp-latency": act_cdp_latency() elif action == "chrome-errors": - if rest: - fail("BAD_ARGS", "usage: chrome-errors") - act_chrome_errors() + if rest and rest != ["--no-advance"]: + fail("BAD_ARGS", "usage: chrome-errors [--no-advance]") + act_chrome_errors(no_advance=rest == ["--no-advance"]) elif action == "main-loop": if not rest or rest[0] not in ("check", "status", "enable", "disable"): fail("BAD_NAME", "usage: main-loop check|status|enable|disable [--agent ]") @@ -3452,9 +4180,20 @@ def main(argv): if not rest or len(rest) > 2: fail("BAD_ARGS", "usage: swarm-kill --confirm") act_swarm_kill(rest[0], confirm=("--confirm" in rest[1:])) + elif op == "prune": + stale_h = 6 + confirm = "--confirm" in rest + if "--stale-hours" in rest: + i = rest.index("--stale-hours") + if i + 1 < len(rest): + try: + stale_h = float(rest[i + 1]) + except ValueError: + pass + act_swarm_prune(stale_hours=stale_h, confirm=confirm) elif action == "swarm": if not rest or rest[0] not in SWARM_OPS: - fail("BAD_NAME", "usage: swarm spawn|list|status|attach|report|results|kill [...]") + fail("BAD_NAME", "usage: swarm spawn|list|status|attach|report|results|kill|prune [...]") sub = rest[0] args = rest[1:] if sub == "spawn": @@ -3593,14 +4332,202 @@ def main(argv): elif action == "approval-auto": node = rest[0] if rest else None act_approval_auto(node) + elif action == "tmux-tally": + if rest: + fail("BAD_ARGS", "usage: tmux-tally") + act_tmux_tally() + elif action == "tmux-auto-status": + if rest: + fail("BAD_ARGS", "usage: tmux-auto-status") + act_tmux_auto_status() + elif action == "tmux-auto-toggle": + args = list(rest) + enable = True + if "--disable" in args: + enable = False + args = [a for a in args if a != "--disable"] + elif "--enable" in args: + enable = True + args = [a for a in args if a != "--enable"] + node = None + session = None + if "--node" in args: + i = args.index("--node") + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: tmux-auto-toggle [--enable|--disable] [--node NODE] [--session SESSION]") + node = args[i + 1] + args = args[:i] + args[i + 2:] + if "--session" in args: + i = args.index("--session") + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: tmux-auto-toggle [--enable|--disable] [--node NODE] [--session SESSION]") + session = args[i + 1] + args = args[:i] + args[i + 2:] + if args: + fail("BAD_ARGS", "usage: tmux-auto-toggle [--enable|--disable] [--node NODE] [--session SESSION]") + act_tmux_auto_toggle(enable=enable, node=node, session=session) + elif action == "tmux-auto-once": + dry_run = "--dry-run" in rest + args = [a for a in rest if a != "--dry-run"] + if args: + fail("BAD_ARGS", "usage: tmux-auto-once [--dry-run]") + act_tmux_auto_once(dry_run=dry_run) + elif action == "tmux": + if not rest: + fail("BAD_NAME", "usage: tmux tally|auto|status|toggle|once [...]") + sub = rest[0] + args = rest[1:] + if sub == "tally": + if args: + fail("BAD_ARGS", "usage: tmux tally") + act_tmux_tally() + elif sub in ("auto", "status"): + if args: + fail("BAD_ARGS", f"usage: tmux {sub}") + act_tmux_auto_status() + elif sub == "toggle": + enable = True + if "--disable" in args: + enable = False + args = [a for a in args if a != "--disable"] + elif "--enable" in args: + enable = True + args = [a for a in args if a != "--enable"] + node = None + session = None + if "--node" in args: + i = args.index("--node") + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: tmux toggle [--enable|--disable] [--node NODE] [--session SESSION]") + node = args[i + 1] + args = args[:i] + args[i + 2:] + if "--session" in args: + i = args.index("--session") + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: tmux toggle [--enable|--disable] [--node NODE] [--session SESSION]") + session = args[i + 1] + args = args[:i] + args[i + 2:] + if args: + fail("BAD_ARGS", "usage: tmux toggle [--enable|--disable] [--node NODE] [--session SESSION]") + act_tmux_auto_toggle(enable=enable, node=node, session=session) + elif sub == "once": + dry_run = "--dry-run" in args + args = [a for a in args if a != "--dry-run"] + if args: + fail("BAD_ARGS", "usage: tmux once [--dry-run]") + act_tmux_auto_once(dry_run=dry_run) + else: + fail("BAD_NAME", "usage: tmux tally|auto|status|toggle|once [...]") + elif action == "onboard-connects": + if rest: + fail("BAD_ARGS", "usage: onboard-connects") + act_onboard_connects() + elif action == "onboard": + if not rest or rest[0] != "connects": + fail("BAD_NAME", "usage: onboard connects") + if len(rest) > 1: + fail("BAD_ARGS", "usage: onboard connects") + act_onboard_connects() elif action == "dm-log": limit = 50 - if rest: + agent = None + args = list(rest) + if "--agent" in args: + i = args.index("--agent") + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: dm-log [limit] [--agent ]") + agent = args[i + 1] + args = args[:i] + args[i + 2:] + if len(args) > 1: + fail("BAD_ARGS", "usage: dm-log [limit] [--agent ]") + if args: try: - limit = int(rest[0]) + limit = int(args[0]) except ValueError: - fail("BAD_LIMIT", "usage: dm-log [limit]") - act_dm_log(limit=limit) + fail("BAD_LIMIT", "usage: dm-log [limit] [--agent ]") + act_dm_log(limit=limit, agent=agent) + elif action == "unread": + agent = None + args = list(rest) + if "--agent" in args: + i = args.index("--agent") + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: unread [--agent ]") + agent = args[i + 1] + args = args[:i] + args[i + 2:] + if args: + fail("BAD_ARGS", "usage: unread [--agent ]") + act_unread(agent=agent) + elif action == "git-status": + if rest: + fail("BAD_ARGS", "usage: git-status") + act_git_status() + elif action == "git-diff": + args = list(rest) + stat = "--stat" in args + args = [a for a in args if a != "--stat"] + path = None + if "--path" in args: + i = args.index("--path") + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: git-diff [--stat] [--path ]") + path = args[i + 1] + args = args[:i] + args[i + 2:] + if args: + fail("BAD_ARGS", "usage: git-diff [--stat] [--path ]") + act_git_diff(stat=stat, path=path) + elif action == "git-log": + args = list(rest) + limit = 10 + path = None + if "--limit" in args: + i = args.index("--limit") + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: git-log [--limit N] [--path ]") + limit = args[i + 1] + args = args[:i] + args[i + 2:] + if "--path" in args: + i = args.index("--path") + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: git-log [--limit N] [--path ]") + path = args[i + 1] + args = args[:i] + args[i + 2:] + if args: + fail("BAD_ARGS", "usage: git-log [--limit N] [--path ]") + act_git_log(limit=limit, path=path) + elif action == "tests-run": + args = list(rest) + filt = None + if "--filter" in args: + i = args.index("--filter") + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: tests-run [tests.] [--filter ]") + filt = args[i + 1] + args = args[:i] + args[i + 2:] + if len(args) > 1: + fail("BAD_ARGS", "usage: tests-run [tests.] [--filter ]") + act_tests_run(args[0] if args else None, filter=filt) + elif action == "ack": + args = list(rest) + allow_main = "--allow-main-chat" in args + args = [a for a in args if a != "--allow-main-chat"] + to = sender = sidechat = None + for flag in ("--to", "--sender", "--sidechat"): + if flag in args: + i = args.index(flag) + if i + 1 >= len(args): + fail("BAD_ARGS", "usage: ack --to --sender [--sidechat ] [--allow-main-chat]") + val = args[i + 1] + args = args[:i] + args[i + 2:] + if flag == "--to": + to = val + elif flag == "--sender": + sender = val + else: + sidechat = val + if len(args) != 1 or not to or not sender: + fail("BAD_ARGS", "usage: ack --to --sender [--sidechat ] [--allow-main-chat]") + act_ack(args[0], to, sender, sidechat=sidechat, allow_main_chat=allow_main) else: print(USAGE, file=sys.stderr) fail("BAD_NAME", f"unknown action: {action}") diff --git a/bin/exec-constrained.py b/bin/exec-constrained.py index 0b7aa6f..080f77b 100755 --- a/bin/exec-constrained.py +++ b/bin/exec-constrained.py @@ -71,6 +71,20 @@ NAME_RE = re.compile(r'^[A-Za-z0-9_.-]{1,64}$') IDENT_RE = re.compile(r'^[a-z0-9-]+$') NONCE_RE = re.compile(r'^[0-9a-fA-F]{16,128}$') HEX_RE = re.compile(r'^[0-9a-f]{8,128}$') +DM_ID_RE = re.compile(r'^[0-9a-fA-F]{6,64}$') +TEST_MODULE_RE = re.compile(r'^tests\.[a-z0-9_]+$') +JOB_DISPATCH_ID_RE = re.compile(r'^[a-z0-9][a-z0-9-]{0,63}-\d{8}-\d{6}-[a-f0-9]{8}$') +STRAT_TYPES = frozenset({'wake', 'job', 'siphon', 'manual', 'health', 'heartbeat'}) +STRAT_PRIORITIES = frozenset({'routine', 'normal', 'important'}) +SUBTYPE_RE = re.compile(r'^[A-Za-z0-9_.-]{1,64}$') +MD_ACCOUNT_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$') +MD_FILENAME_RE = re.compile(r'^[A-Za-z0-9_.-]{1,128}$') +MD_SUBPATH_RE = re.compile(r'^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$') +MD_TEMPLATE_FILES = frozenset({'SOUL.md', 'PROACTIVE_PREFERENCES.md', + 'HEARTBEAT.md', 'AGENTS.md', 'MEMORY.md', + 'USER.md', 'TOOLS.md', 'IDENTITY.md'}) +MD_MAX_AMEND = 256 * 1024 +MD_MAX_APPEND = 64 * 1024 MAX_BODY = 64 * 1024 # 64KB request cap MAX_MESSAGE = 2000 # dm.py message cap @@ -994,6 +1008,828 @@ def _swarm_results_build(a): return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), 'swarm-results', a['swarm_id']] +def _fleet_unread_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'agent'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + agent = raw.get('agent') + return {'agent': _agent(agent) if agent else None} + + +def _fleet_unread_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), 'unread'] + if a.get('agent'): + argv += ['--agent', a['agent']] + return argv + + +def _dm_log_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'limit', 'agent'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + agent = raw.get('agent') + return { + 'limit': _opt_int(raw.get('limit', 20), 1, 100, 'limit') or 20, + 'agent': _agent(agent) if agent else None, + } + + +def _dm_log_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'dm-log', str(a['limit'])] + if a.get('agent'): + argv += ['--agent', a['agent']] + return argv + + +def _repo_path(v): + # Repo-relative path, no escapes. Mirrors box-ctl.py _git_path. + if not isinstance(v, str) or not v.strip(): + raise OpError('path must be a non-empty string') + if '..' in v or v.startswith('/') or \ + any(ord(c) < 32 or ord(c) == 127 for c in v): + raise OpError("path must be repo-relative without '..'") + return v + + +def _sidechat_name(v): + # Sidechat names may contain spaces ("646 tasks"); reject only + # control characters and enforce length. Mirrors box-ctl.py. + if not isinstance(v, str) or not v or len(v) > 64: + raise OpError('sidechat must be 1-64 chars') + if any(ord(c) < 32 or ord(c) == 127 for c in v): + raise OpError('sidechat contains control characters') + return v + + +def _git_diff_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'path', 'stat'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + path = raw.get('path') + return { + 'path': _repo_path(path) if path is not None else None, + 'stat': bool(raw.get('stat', False)), + } + + +def _git_diff_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), 'git-diff'] + if a.get('stat'): + argv.append('--stat') + if a.get('path'): + argv += ['--path', a['path']] + return argv + + +def _git_log_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'limit', 'path'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + path = raw.get('path') + return { + 'limit': _opt_int(raw.get('limit', 10), 1, 50, 'limit') or 10, + 'path': _repo_path(path) if path is not None else None, + } + + +def _git_log_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'git-log', '--limit', str(a['limit'])] + if a.get('path'): + argv += ['--path', a['path']] + return argv + + +def _kfilter(v): + # unittest -k pattern: plain string, passed as argv (no shell). + if not isinstance(v, str) or not v.strip() or len(v) > 200: + raise OpError('filter must be 1-200 chars') + if any(ord(c) < 32 or ord(c) == 127 for c in v): + raise OpError('filter contains control characters') + return v + + +def _tests_run_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'test', 'filter'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + test = raw.get('test') + filt = raw.get('filter') + if test is not None and \ + (not isinstance(test, str) or not TEST_MODULE_RE.fullmatch(test)): + raise OpError('test must match ^tests\\.[a-z0-9_]+$') + return { + 'test': test, + 'filter': _kfilter(filt) if filt is not None else None, + } + + +def _tests_run_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), 'tests-run'] + if a.get('test'): + argv.append(a['test']) + if a.get('filter'): + argv += ['--filter', a['filter']] + return argv + + +def _notify_send_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'agent', 'message', 'sidechat', 'sender'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + msg = _clean_message(raw.get('message')) + if len(msg) > 1000: + raise OpError('message too long (max 1000)') + sidechat = raw.get('sidechat') + sender = raw.get('sender') + return { + 'agent': _agent(raw.get('agent')), + 'message': msg, + 'sidechat': _sidechat_name(sidechat) if sidechat is not None else None, + 'sender': _agent(sender) if sender is not None else None, + } + + +def _notify_send_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'notify', a['agent'], a['message']] + if a.get('sidechat'): + argv += ['--sidechat', a['sidechat']] + if a.get('sender'): + argv += ['--sender', a['sender']] + return argv + + +def _dm_ack_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'id', 'to', 'sender', 'sidechat', 'allow_main_chat'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + ref_id = raw.get('id') + if not isinstance(ref_id, str) or not DM_ID_RE.fullmatch(ref_id): + raise OpError('id must be 6-64 hex chars') + sender = raw.get('sender') + if not sender: + raise OpError('sender is required') + sidechat = raw.get('sidechat') + return { + 'id': ref_id, + 'to': _agent(raw.get('to')), + 'sender': _agent(sender), + 'sidechat': _sidechat_name(sidechat) if sidechat is not None else None, + 'allow_main_chat': bool(raw.get('allow_main_chat', False)), + } + + +def _dm_ack_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'ack', a['id'], '--to', a['to'], '--sender', a['sender']] + if a.get('sidechat'): + argv += ['--sidechat', a['sidechat']] + if a.get('allow_main_chat'): + argv.append('--allow-main-chat') + return argv + + +def _job_put_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'name', 'definition'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + name = raw.get('name') + if not isinstance(name, str) or not JOB_RE.fullmatch(name): + raise OpError('name must match ^[a-z0-9][a-z0-9-]{0,63}$') + definition = raw.get('definition') + if not isinstance(definition, dict): + raise OpError('definition must be an object') + if definition.get('name') != name: + raise OpError('definition name must match name') + # Full schema is enforced by box-ctl.py validate_job (single copy). + return {'name': name, 'definition': definition} + + +def _job_put_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'job-put', a['name']] + + +def _job_trigger_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'name'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + return {'name': _job_name(raw.get('name'))} + + +def _job_trigger_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'job-trigger', a['name']] + + +def _job_chain_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'from', 'to', 'on_failure'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + # Self-chain and cycle guards live in box-ctl.py (single copy). + return { + 'from': _job_name(raw.get('from')), + 'to': _job_name(raw.get('to')), + 'on_failure': bool(raw.get('on_failure', False)), + } + + +def _job_chain_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'job-chain', a['from'], a['to']] + if a.get('on_failure'): + argv.append('--on-failure') + return argv + + +def _job_next_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'job_id', 'success'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + job_id = raw.get('job_id') + if not isinstance(job_id, str) or not JOB_DISPATCH_ID_RE.fullmatch(job_id): + raise OpError('job_id must look like -YYYYMMDD-HHMMSS-<8hex>') + success = raw.get('success') + if success is not None and not isinstance(success, bool): + raise OpError('success must be a boolean') + return {'job_id': job_id, 'success': success} + + +def _job_next_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'job-next', a['job_id']] + if a.get('success') is True: + argv.append('--success') + elif a.get('success') is False: + argv.append('--fail') + return argv + + +def _timer_stop_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'name'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + return {'name': _job_name(raw.get('name'))} + + +def _timer_stop_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'timer-stop', a['name']] + + +def _timer_disable_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'timer-disable', a['name']] + + +def _loop_remediate_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'dry_run'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + return {'dry_run': bool(raw.get('dry_run', False))} + + +def _loop_remediate_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'loop-remediate'] + if a.get('dry_run'): + argv.append('--dry-run') + return argv + + +def _loop_resolve_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'dm_id', 'note'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + dm_id = raw.get('dm_id') + if not isinstance(dm_id, str) or not DM_ID_RE.fullmatch(dm_id): + raise OpError('dm_id must be 6-64 hex chars') + note = raw.get('note') + return { + 'dm_id': dm_id, + 'note': _clean_message(note) if note is not None else None, + } + + +def _loop_resolve_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'loop-resolve', a['dm_id']] + if a.get('note'): + argv.append(a['note']) + return argv + + +def _strat_type(v): + if not isinstance(v, str) or v.lower() not in STRAT_TYPES: + raise OpError('type must be one of %s' % sorted(STRAT_TYPES)) + return v.lower() + + +def _strat_common(raw, allowed): + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + subtype = raw.get('subtype') + if subtype is not None and \ + (not isinstance(subtype, str) or not SUBTYPE_RE.fullmatch(subtype)): + raise OpError('subtype must match ^[A-Za-z0-9_.-]{1,64}$') + agent = raw.get('agent') + return subtype, _agent(agent) if agent is not None else None + + +def _strat_int(v, field): + if isinstance(v, bool) or not isinstance(v, int): + raise OpError(f'{field} must be an integer') + return v + + +def _strat_set_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'type', 'subtype', 'agent', 'track', 'priority', + 'timeout_s', 'nudges', 'escalate'} + subtype, agent = _strat_common(raw, allowed) + track = raw.get('track') + if track is not None and not isinstance(track, bool): + raise OpError('track must be a boolean') + priority = raw.get('priority') + if priority is not None: + if not isinstance(priority, str) or \ + priority.lower() not in STRAT_PRIORITIES: + raise OpError('priority must be one of %s' + % sorted(STRAT_PRIORITIES)) + priority = priority.lower() + timeout_s = raw.get('timeout_s') + if timeout_s is not None: + timeout_s = _strat_int(timeout_s, 'timeout_s') + nudges = raw.get('nudges') + if nudges is not None: + nudges = _strat_int(nudges, 'nudges') + escalate = raw.get('escalate') + if escalate is not None: + if not isinstance(escalate, str) or not escalate.strip() or \ + len(escalate) > 64 or \ + any(ord(c) < 32 or ord(c) == 127 for c in escalate): + raise OpError('escalate must be 1-64 chars, no controls') + return { + 'type': _strat_type(raw.get('type')), + 'subtype': subtype, 'agent': agent, 'track': track, + 'priority': priority, 'timeout_s': timeout_s, 'nudges': nudges, + 'escalate': escalate, + } + + +def _strat_set_build(a): + payload = {} + for k in ('subtype', 'agent', 'track', 'priority', 'timeout_s', + 'nudges', 'escalate'): + if a.get(k) is not None: + payload[k] = a[k] + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'strat-set', a['type'], json.dumps(payload)] + + +def _strat_reset_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'type', 'subtype', 'agent'} + subtype, agent = _strat_common(raw, allowed) + return {'type': _strat_type(raw.get('type')), + 'subtype': subtype, 'agent': agent} + + +def _strat_reset_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'strat-reset', a['type']] + if a.get('subtype'): + argv.append(a['subtype']) + if a.get('agent'): + argv += ['--agent', a['agent']] + return argv + + +def _vars_name_validate(raw, allowed): + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + name = raw.get('name') + if not isinstance(name, str) or not NAME_RE.fullmatch(name): + raise OpError('name must match safe identifier') + return name + + +def _vars_reset_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + return {'name': _vars_name_validate(raw, {'name'})} + + +def _vars_reset_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'vars-reset', a['name']] + + +def _vars_rollback_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + name = _vars_name_validate(raw, {'name', 'revision'}) + revision = raw.get('revision') + if revision is not None: + if isinstance(revision, bool): + raise OpError('revision must be an int step or timestamp') + if isinstance(revision, int): + if revision < 1: + raise OpError('revision step must be >= 1') + elif not isinstance(revision, str) or not revision.strip() or \ + len(revision) > 64 or \ + any(ord(c) < 32 or ord(c) == 127 for c in revision): + raise OpError('revision must be an int step or timestamp') + return {'name': name, 'revision': revision} + + +def _vars_rollback_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'vars-rollback', a['name']] + if a.get('revision') is not None: + argv.append(str(a['revision'])) + return argv + + +def _md_check_keys(raw, allowed): + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + + +def _md_account(v): + if not isinstance(v, str) or not MD_ACCOUNT_RE.fullmatch(v): + raise OpError('account must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$') + return v + + +def _md_filename(v, template_only=False): + if template_only: + if v not in MD_TEMPLATE_FILES: + raise OpError('filename must be one of %s' + % sorted(MD_TEMPLATE_FILES)) + return v + if not isinstance(v, str) or v in ('.', '..') \ + or not MD_FILENAME_RE.fullmatch(v): + raise OpError('filename must be a plain basename (no directories)') + return v + + +def _md_subpath(v): + if v in (None, ''): + return '' + if not isinstance(v, str) or not MD_SUBPATH_RE.fullmatch(v) \ + or '..' in v.split('/'): + raise OpError('path must be a subdir without ..') + return v + + +def _md_audit_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _md_check_keys(raw, {'accounts'}) + accounts = raw.get('accounts') + if accounts is None: + return {'accounts': None} + if not isinstance(accounts, list) or not accounts: + raise OpError('accounts must be a non-empty list') + return {'accounts': [_md_account(a) for a in accounts]} + + +def _md_audit_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), 'md-audit'] + if a.get('accounts'): + argv += a['accounts'] + return argv + + +def _md_list_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _md_check_keys(raw, {'account', 'path'}) + return {'account': _md_account(raw.get('account')), + 'path': _md_subpath(raw.get('path'))} + + +def _md_list_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'md-list', a['account']] + if a.get('path'): + argv.append(a['path']) + return argv + + +def _md_read_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _md_check_keys(raw, {'account', 'filename'}) + return {'account': _md_account(raw.get('account')), + 'filename': _md_filename(raw.get('filename'))} + + +def _md_read_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'md-read', a['account'], a['filename']] + + +def _md_diff_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _md_check_keys(raw, {'account', 'filename'}) + return {'account': _md_account(raw.get('account')), + 'filename': _md_filename(raw.get('filename'), template_only=True)} + + +def _md_diff_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'md-diff', a['account'], a['filename']] + + +def _md_pull_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _md_check_keys(raw, {'account', 'filename'}) + return {'account': _md_account(raw.get('account')), + 'filename': _md_filename(raw.get('filename'), template_only=True)} + + +def _md_pull_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'md-pull', a['account'], a['filename']] + + +def _md_inject_drive_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _md_check_keys(raw, {'account'}) + return {'account': _md_account(raw.get('account'))} + + +def _md_inject_drive_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'md-inject-drive', a['account']] + + +def _md_sync_all_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _md_check_keys(raw, set()) + return {} + + +def _md_sync_all_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), 'md-sync-all'] + + +def _md_author(v): + if not isinstance(v, str) or not v.strip() or len(v) > 64 or \ + any(ord(c) < 32 or ord(c) == 127 for c in v): + raise OpError('author must be 1-64 chars, no controls') + return v + + +def _md_reason(v): + if not isinstance(v, str) or len(v) > 256 or \ + any(ord(c) < 32 or ord(c) == 127 for c in v): + raise OpError('reason must be 0-256 chars, no controls') + return v + + +def _md_amend_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _md_check_keys(raw, {'filename', 'content', 'author', 'reason'}) + content = raw.get('content') + if not isinstance(content, str) or not content.strip() \ + or len(content) > MD_MAX_AMEND: + raise OpError('content must be 1-%d chars' % MD_MAX_AMEND) + return {'filename': _md_filename(raw.get('filename'), template_only=True), + 'content': content, + 'author': _md_author(raw.get('author', 'operator')), + 'reason': _md_reason(raw.get('reason', ''))} + + +def _md_amend_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'md-amend', a['filename'], '--stdin', + '--author', a['author']] + if a.get('reason'): + argv += ['--reason', a['reason']] + return argv + + +def _md_append_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _md_check_keys(raw, {'filename', 'text', 'author', 'section'}) + text = raw.get('text') + if not isinstance(text, str) or not text.strip() \ + or len(text) > MD_MAX_APPEND: + raise OpError('text must be 1-%d chars' % MD_MAX_APPEND) + section = raw.get('section') + if section is not None: + if not isinstance(section, str) or not section.strip() \ + or len(section) > 128 or \ + any(ord(c) < 32 or ord(c) == 127 for c in section): + raise OpError('section must be 1-128 chars, no controls') + return {'filename': _md_filename(raw.get('filename'), template_only=True), + 'text': text, + 'author': _md_author(raw.get('author', 'operator')), + 'section': section} + + +def _md_append_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'md-append', a['filename'], '--stdin', + '--author', a['author']] + if a.get('section'): + argv += ['--section', a['section']] + return argv + + +def _approval_keys(raw, allowed): + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + + +def _approval_opt_node(raw): + node = raw.get('node') + if node is None: + return None + return _agent(node) + + +def _approval_main_chat(raw): + v = raw.get('allow_main_chat', False) + if not isinstance(v, bool): + raise OpError('allow_main_chat must be a boolean') + return v + + +def _approval_check_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _approval_keys(raw, {'node'}) + return {'node': _approval_opt_node(raw)} + + +def _approval_check_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'approval-check'] + if a.get('node'): + argv.append(a['node']) + return argv + + +def _approval_deny_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _approval_keys(raw, {'node', 'message', 'allow_main_chat'}) + return {'node': _agent(raw.get('node')), + 'message': _clean_message(raw.get('message')), + 'allow_main_chat': _approval_main_chat(raw)} + + +def _approval_deny_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'approval-deny', a['node'], '--message', a['message']] + if a.get('allow_main_chat'): + argv.append('--allow-main-chat') + return argv + + +def _approval_auto_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _approval_keys(raw, {'node'}) + return {'node': _approval_opt_node(raw)} + + +def _approval_auto_build(a): + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'approval-auto'] + if a.get('node'): + argv.append(a['node']) + return argv + + +def _approval_allow_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + _approval_keys(raw, {'node', 'message', 'allow_main_chat'}) + return {'node': _agent(raw.get('node')), + 'message': _clean_message(raw.get('message')), + 'allow_main_chat': _approval_main_chat(raw)} + + +def _approval_allow_build(a): + # One-shot only: --always/--force are never passed remotely. + argv = [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), + 'approval-allow', a['node'], '--message', a['message']] + if a.get('allow_main_chat'): + argv.append('--allow-main-chat') + return argv + + +def _tmux_tally_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + return {} + + +def _tmux_tally_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'tmux_auto_approver.py'), 'tally', '--json'] + + +def _tmux_auto_status_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + return {} + + +def _tmux_auto_status_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'tmux_auto_approver.py'), 'status', '--json'] + + +def _onboard_connects_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + return {} + + +def _onboard_connects_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'onboard_pipeline.py'), 'connects', '--json'] + + +def _stdin_body(op, clean): + """Request body piped to the backend's stdin (or None). + + Most ops pass everything via argv. box-sys-op.py file/web/service/ + followup ops consume the validated envelope; box-ctl.py job-put + consumes the raw job definition (box-ctl matches definition.name + against the argv name itself); box-ctl.py md-amend/md-append consume + the raw markdown content (argv carries filename, flags, --stdin). + """ + if op == 'job.put': + return json.dumps(clean['definition']) + if op == 'md.amend': + return clean['content'] + if op == 'md.append': + return clean['text'] + if op.startswith(('files.', 'web.', 'service.', 'followup.')): + return json.dumps(clean) + return None + + # box.exec allowlist: read-only box-ctl actions only (mirrors the read-only # subset of box-ctl.py IDEMPOTENT_ACTIONS). Actions with side-effecting # subverbs (main-loop enable/disable), path args (git-diff/git-log), or @@ -1072,11 +1908,66 @@ OPS = { 'timeout': 60, 'side_effecting': False, 'desc': 'Read recent DMs (read-only)', }, + 'dm.log': { + 'validate': _dm_log_validate, 'build': _dm_log_build, + 'timeout': 30, 'side_effecting': False, + 'desc': 'Tail the DM send log, optionally agent-scoped (read-only)', + }, + 'dm.ack': { + 'validate': _dm_ack_validate, 'build': _dm_ack_build, + 'timeout': 150, 'side_effecting': True, + 'desc': 'Acknowledge a DM or work order as [ACK:id] (sidechat-first)', + }, + 'notify.send': { + 'validate': _notify_send_validate, 'build': _notify_send_build, + 'timeout': 150, 'side_effecting': True, + 'desc': 'Notify an agent in its sidechat (sidechat-first)', + }, 'job.run': { 'validate': _job_run_validate, 'build': _job_run_build, 'timeout': 300, 'side_effecting': True, 'desc': 'Run a job from the jobs directory', }, + 'job.put': { + 'validate': _job_put_validate, 'build': _job_put_build, + 'timeout': 60, 'side_effecting': True, + 'desc': 'Create or update a job definition (schema-validated, committed)', + }, + 'job.trigger': { + 'validate': _job_trigger_validate, 'build': _job_trigger_build, + 'timeout': 330, 'side_effecting': True, + 'desc': 'Trigger a job dispatch now (audited JSON contract)', + }, + 'job.chain': { + 'validate': _job_chain_validate, 'build': _job_chain_build, + 'timeout': 60, 'side_effecting': True, + 'desc': 'Wire chain_next (or on_failure) between two jobs', + }, + 'job.next': { + 'validate': _job_next_validate, 'build': _job_next_build, + 'timeout': 30, 'side_effecting': False, + 'desc': 'Dry-run: what would dispatch next for a job id (read-only)', + }, + 'loop.remediate': { + 'validate': _loop_remediate_validate, 'build': _loop_remediate_build, + 'timeout': 300, 'side_effecting': True, + 'desc': 'Auto-heal soft loop breaks (supports dry_run preview)', + }, + 'loop.resolve': { + 'validate': _loop_resolve_validate, 'build': _loop_resolve_build, + 'timeout': 30, 'side_effecting': True, + 'desc': 'Mark a followup loop resolved with an optional note', + }, + 'strat.set': { + 'validate': _strat_set_validate, 'build': _strat_set_build, + 'timeout': 30, 'side_effecting': True, + 'desc': 'Set a strategy override (type-scoped, validated)', + }, + 'strat.reset': { + 'validate': _strat_reset_validate, 'build': _strat_reset_build, + 'timeout': 30, 'side_effecting': True, + 'desc': 'Reset a strategy override to built-in default', + }, 'chat.messages': { 'validate': _chat_messages_validate, 'build': _chat_messages_build, 'timeout': 60, 'side_effecting': False, @@ -1092,6 +1983,112 @@ OPS = { 'timeout': 30, 'side_effecting': False, 'desc': 'Run fleet status health check (read-only)', }, + 'fleet.unread': { + 'validate': _fleet_unread_validate, 'build': _fleet_unread_build, + 'timeout': 60, 'side_effecting': False, + 'desc': 'Fleet unread/activity counts, optionally agent-scoped (read-only)', + }, + 'git.status': { + 'validate': _health_validate, + 'build': lambda a: [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), 'git-status'], + 'timeout': 30, 'side_effecting': False, + 'desc': 'Git working-tree status short + branch (read-only)', + }, + 'git.diff': { + 'validate': _git_diff_validate, 'build': _git_diff_build, + 'timeout': 30, 'side_effecting': False, + 'desc': 'Git diff, capped at 64KB, optional path/stat (read-only)', + }, + 'git.log': { + 'validate': _git_log_validate, 'build': _git_log_build, + 'timeout': 30, 'side_effecting': False, + 'desc': 'Recent commits as sha/subject, optional path (read-only)', + }, + 'tests.run': { + 'validate': _tests_run_validate, 'build': _tests_run_build, + 'timeout': 600, 'side_effecting': True, + 'desc': 'Run repo unit tests: one tests. or full suite, optional -k filter', + }, + 'md.audit': { + 'validate': _md_audit_validate, 'build': _md_audit_build, + 'timeout': 300, 'side_effecting': False, + 'desc': 'Audit agent .md files + drive score across fleet (read-only)', + }, + 'md.list': { + 'validate': _md_list_validate, 'build': _md_list_build, + 'timeout': 120, 'side_effecting': False, + 'desc': 'List agent container files, capped at 200 entries (read-only)', + }, + 'md.read': { + 'validate': _md_read_validate, 'build': _md_read_build, + 'timeout': 120, 'side_effecting': False, + 'desc': 'Read an agent container file, capped at 64KB (read-only)', + }, + 'md.diff': { + 'validate': _md_diff_validate, 'build': _md_diff_build, + 'timeout': 120, 'side_effecting': False, + 'desc': 'Diff agent file against shared template, capped at 64KB (read-only)', + }, + 'md.pull': { + 'validate': _md_pull_validate, 'build': _md_pull_build, + 'timeout': 120, 'side_effecting': True, + 'desc': 'Pull canonical shared template into an agent container', + }, + 'md.inject_drive': { + 'validate': _md_inject_drive_validate, 'build': _md_inject_drive_build, + 'timeout': 300, 'side_effecting': True, + 'desc': 'Inject high-drive operator templates into one agent', + }, + 'md.sync_all': { + 'validate': _md_sync_all_validate, 'build': _md_sync_all_build, + 'timeout': 600, 'side_effecting': True, + 'desc': 'Inject high-drive operator templates across all agents', + }, + 'md.amend': { + 'validate': _md_amend_validate, 'build': _md_amend_build, + 'timeout': 120, 'side_effecting': True, + 'desc': 'Amend a shared operator template (validated, git-committed)', + }, + 'md.append': { + 'validate': _md_append_validate, 'build': _md_append_build, + 'timeout': 120, 'side_effecting': True, + 'desc': 'Append a note to a shared operator template (git-committed)', + }, + 'approval.check': { + 'validate': _approval_check_validate, 'build': _approval_check_build, + 'timeout': 180, 'side_effecting': False, + 'desc': 'Fleet browser approval status, optionally node-scoped (read-only)', + }, + 'approval.deny': { + 'validate': _approval_deny_validate, 'build': _approval_deny_build, + 'timeout': 120, 'side_effecting': True, + 'desc': 'Deny a node pending approval (message required)', + }, + 'approval.auto': { + 'validate': _approval_auto_validate, 'build': _approval_auto_build, + 'timeout': 300, 'side_effecting': True, + 'desc': 'Auto-allow TRUSTED non-key prompts, fleet or one node', + }, + 'approval.allow': { + 'validate': _approval_allow_validate, 'build': _approval_allow_build, + 'timeout': 120, 'side_effecting': True, + 'desc': 'One-shot allow of a node pending approval (message required; no always/force)', + }, + 'tmux.tally': { + 'validate': _tmux_tally_validate, 'build': _tmux_tally_build, + 'timeout': 30, 'side_effecting': False, + 'desc': 'Tally all tmux sessions, workers, and panes across fleet sockets (JSON, read-only)', + }, + 'tmux.auto_status': { + 'validate': _tmux_auto_status_validate, 'build': _tmux_auto_status_build, + 'timeout': 30, 'side_effecting': False, + 'desc': 'Tmux auto-approval runtime status, toggle state, and rule inventory (JSON, read-only)', + }, + 'onboard.connects': { + 'validate': _onboard_connects_validate, 'build': _onboard_connects_build, + 'timeout': 30, 'side_effecting': False, + 'desc': 'Consolidated active fleet and client onboard connects (JSON, read-only)', + }, 'cdp.latency': { 'validate': _health_validate, 'build': _cdp_latency_build, 'timeout': 30, 'side_effecting': False, @@ -1167,6 +2164,16 @@ OPS = { 'timeout': 30, 'side_effecting': True, 'desc': 'Enable and start systemd user timer unit for a job', }, + 'cron.timer_stop': { + 'validate': _timer_stop_validate, 'build': _timer_stop_build, + 'timeout': 30, 'side_effecting': True, + 'desc': 'Stop the systemd user timer unit for a job', + }, + 'cron.timer_disable': { + 'validate': _timer_stop_validate, 'build': _timer_disable_build, + 'timeout': 30, 'side_effecting': True, + 'desc': 'Disable the systemd user timer unit for a job', + }, 'vars.list': { 'validate': _vars_list_validate, 'build': _vars_list_build, 'timeout': 30, 'side_effecting': False, @@ -1182,6 +2189,16 @@ OPS = { 'timeout': 30, 'side_effecting': True, 'desc': 'Update intrinsic loop variable', }, + 'vars.reset': { + 'validate': _vars_reset_validate, 'build': _vars_reset_build, + 'timeout': 30, 'side_effecting': True, + 'desc': 'Restore a loop variable to its default value', + }, + 'vars.rollback': { + 'validate': _vars_rollback_validate, 'build': _vars_rollback_build, + 'timeout': 30, 'side_effecting': True, + 'desc': 'Roll a loop variable back to a previous revision', + }, 'files.read': { 'validate': _files_read_validate, 'build': _files_read_build, 'timeout': 30, 'side_effecting': False, @@ -1356,7 +2373,11 @@ PERMISSIONS = { 'def': set(OPS), 'exec-canary': {'exec.ping'}, } -DEFAULT_PERMS = {'dm.read', 'chat.messages', 'health.check', 'thread.list', 'thread.view', 'exec.ping'} +DEFAULT_PERMS = {'dm.read', 'dm.log', 'chat.messages', 'health.check', 'fleet.unread', + 'thread.list', 'thread.view', 'exec.ping', + 'git.status', 'git.diff', 'git.log', 'job.next', + 'md.audit', 'md.list', 'md.read', 'md.diff', + 'approval.check', 'tmux.tally', 'tmux.auto_status', 'onboard.connects'} def permitted(ident, op): @@ -1522,7 +2543,7 @@ class Handler(BaseHTTPRequestHandler): ).hexdigest()[:16]}) t0 = time.monotonic() try: - stdin_input = json.dumps(clean) if op.startswith(('files.', 'web.', 'service.', 'followup.')) else None + stdin_input = _stdin_body(op, clean) p = subprocess.run(argv, input=stdin_input, capture_output=True, text=True, timeout=spec['timeout'], cwd=WORK_DIR) rc = p.returncode @@ -1579,7 +2600,6 @@ def main(): for k, v in sorted(OPS.items())], })) return - args = ap.parse_args() TOKEN_FILE, TOKEN_DIR = args.token_file, args.token_dir SIGNERS_FILE, NONCE_FILE = args.signers_file, args.nonce_file diff --git a/bin/super-cli.py b/bin/super-cli.py index 5d42b55..a5c4331 100755 --- a/bin/super-cli.py +++ b/bin/super-cli.py @@ -70,6 +70,7 @@ DEFAULT_AGENT_SIDECHATS = { "pip": "646-pip-coord", "muse": "muse tasks", } +MUSE_TMUX_LOG_DIR = NETVM_ROOT / "logs" / "tmux" # --------------------------------------------------------------------------- # ANSI Color & Formatting Engine @@ -1082,6 +1083,96 @@ def cmd_runtime(args): print(" %s watcher %s" % (badge_ok("STARTED"), c_cyan(s))) print() + elif action == "layout": + sock = getattr(args, "socket", None) + rows = mcw.all_runtime_rows([sock] if sock else None) + minimum = {"width": mcw.MIN_APPROVAL_WIDTH, + "height": mcw.MIN_APPROVAL_HEIGHT} + if as_json: + print(json.dumps({"ok": True, "runtimes": rows, + "minimum": minimum}, indent=2)) + return + print(c_bold("\n=== RUNTIME LAYOUT ===\n")) + print(c_dim(" Minimum for reliable approvals: %dx%d\n" % ( + minimum["width"], minimum["height"]))) + if not rows: + print(c_dim(" No panes found.")) + print() + return + headers = ["SOCKET", "SESSION", "PANE", "DIMS", "GEO", "CMD"] + table = [] + for r in rows: + dims = ("%dx%d" % (r["width"], r["height"]) + if r["width"] is not None else "-") + if not r["is_muse"]: + geo = badge_dim("-") + elif r["squeezed"]: + geo = badge_err("SQUEEZED") + else: + geo = badge_ok("OK") + table.append([os.path.basename(r["socket"]), + "%s:%s" % (r["session"], r["window"]), + r["pane"], dims, geo, (r["cmd"] or "")[:26]]) + print_table(headers, table) + targets = mcw.spread_targets(rows) + if targets: + print(c_yellow(" %d squeezed runtime(s): %s" % ( + len(targets), ", ".join( + "%s:%s" % (t["session"], t["pane"]) + for t in targets)))) + print(c_dim(" Run `box runtime spread` to break them into " + "own windows.")) + print() + + elif action == "spread": + sock = getattr(args, "socket", None) + session = getattr(args, "session", None) + dry_run = getattr(args, "dry_run", False) + rows = mcw.all_runtime_rows([sock] if sock else None) + if session: + rows = [r for r in rows if r["session"] == session] + targets = mcw.spread_targets(rows) + if dry_run: + if as_json: + print(json.dumps({"ok": True, "dry_run": True, + "targets": targets}, indent=2)) + return + print(c_bold("\n=== RUNTIME SPREAD (dry-run) ===\n")) + if not targets: + print(c_dim(" No squeezed runtimes; nothing to spread.")) + for t in targets: + print(" Would break %s %s:%s (%dx%d) into own " + "window" % (t["socket"], t["session"], t["pane"], + t["width"], t["height"])) + print() + return + spread, failed = [], [] + for t in targets: + name = "spread-%s" % t["pane"] + r = mcw._tmux(t["socket"], "break-pane", "-s", t["pane"], + "-n", name, timeout=15) + if r.returncode == 0: + spread.append("%s:%s" % (t["session"], t["pane"])) + else: + failed.append({ + "pane": "%s:%s" % (t["session"], t["pane"]), + "error": (r.stderr or r.stdout or "").strip() + or "tmux error"}) + if as_json: + print(json.dumps({"ok": not failed, "spread": spread, + "failed": failed}, indent=2)) + return + print(c_bold("\n=== RUNTIME SPREAD ===\n")) + if not targets: + print(c_dim(" No squeezed runtimes; nothing to spread.")) + for s in spread: + print(" %s spread %s (watchers follow pane ids)" % ( + badge_ok("MOVED"), c_cyan(s))) + for f in failed: + print(" %s %s: %s" % ( + badge_err("FAILED"), f["pane"], f["error"])) + print() + else: if as_json: print(json.dumps({"ok": False, @@ -5100,10 +5191,35 @@ def cmd_passkey_info(args): def _lookup_unreads(args): + data = collect_fleet_data() + as_json = getattr(args, "json", False) + if as_json: + nodes_out = [] + for item in data: + n = item.get("node") + title = item.get("title", "") + unread_cnt = 0 + if "(1)" in title or "(2)" in title or "(3)" in title: + m = re.search(r"\((\d+)\)", title) + unread_cnt = int(m.group(1)) if m else 1 + thread_info = "-" + if "thread/" in item.get("url", ""): + thread_info = item["url"].split("thread/")[-1][:12] + elif item.get("url") == "https://muse.ai/": + thread_info = "home" + nodes_out.append({ + "node": n, + "unread": unread_cnt, + "title": title, + "thread": thread_info, + "approval_pending": bool(item.get("approval_pending")), + }) + print(json.dumps({"ok": True, "nodes": nodes_out})) + return + print("\n" + c_bold("=== FLEET UNREAD / ACTIVITY STATUS ===") + "\n") headers = ["NODE", "UNREAD COUNT", "ACTIVE PAGE / TITLE", "LAST SEEN / THREAD"] rows = [] - data = collect_fleet_data() for item in data: n = item["node"] title = item.get("title", "") @@ -5157,6 +5273,8 @@ def cmd_lookup(args): cmd_fleet_status(args) elif sub in ("threads", "sidechats"): cmd_thread_list(args) + elif sub in ("unread", "unreads"): + _lookup_unreads(args) elif sub in ("approvals", "approval"): cmd_approvals(args) elif sub in ("docs", "doc", "surfaces", "sentence", "regex", "parse"): @@ -5169,6 +5287,159 @@ def cmd_lookup(args): print(f"Unknown lookup target '{sub}'. Choose from: fleet, threads, unread, approvals, key, docs", file=sys.stderr) +def _sanitize_tmux_name(name: str) -> str: + cleaned = re.sub(r"[^a-zA-Z0-9_-]", "-", name).strip("-") + cleaned = re.sub(r"-+", "-", cleaned) + return cleaned or "run" + + +def _write_watch_script(session: str) -> tuple[str, str]: + watch_session = f"{session}-watch" + script_path = f"/tmp/{watch_session}.sh" + content = f"""#!/bin/bash +target="{session}" +exit_file="/tmp/{session}.exit" +max=200 +count=0 + +while [ $count -lt $max ]; do + if ! tmux -S /tmp/tmux-muse.sock has-session -t "$target" 2>/dev/null; then + break + fi + pane_content=$(tmux -S /tmp/tmux-muse.sock capture-pane -p -t "$target" 2>/dev/null) + if echo "$pane_content" | grep -q "› 1. Yes, proceed"; then + tmux -S /tmp/tmux-muse.sock send-keys -t "$target" "1" Enter + fi + sleep 1 + count=$((count+1)) +done +touch "$exit_file" +""" + p = Path(script_path) + p.write_text(content, encoding="utf-8") + os.chmod(script_path, 0o755) + return watch_session, script_path + + +def cmd_run(args): + """Run a headless muse-code session in tmux on /tmp/tmux-muse.sock.""" + if not shutil.which("tmux"): + print("tmux not found", file=sys.stderr) + sys.exit(2) + if not shutil.which("muse-code"): + print("muse-code not found", file=sys.stderr) + sys.exit(2) + + prompt = getattr(args, "prompt", None) + prompt_file = getattr(args, "prompt_file", None) + if not prompt and not prompt_file: + if sys.stdin.isatty(): + print("Error: prompt or --prompt-file required", file=sys.stderr) + sys.exit(2) + prompt = sys.stdin.read() + if not prompt.strip(): + print("Error: empty prompt", file=sys.stderr) + sys.exit(2) + + raw_session = getattr(args, "session", None) or f"run-{int(time.time())}" + session = _sanitize_tmux_name(raw_session) + auto_approve = getattr(args, "auto_approve", False) + + prompt_path = f"/tmp/{session}.prompt" + if prompt: + Path(prompt_path).write_text(prompt if prompt.endswith("\n") else prompt + "\n", encoding="utf-8") + elif prompt_file: + prompt_path = prompt_file + + wrapper_path = f"/tmp/{session}.sh" + cmd_parts = [ + "cd /home/super/Projects/NetVM", + f"muse-code --prompt-file {prompt_path}", + ] + if getattr(args, "provider", None): + cmd_parts.append(f"--provider {args.provider}") + if getattr(args, "model", None): + cmd_parts.append(f"--model {args.model}") + if getattr(args, "effort", None): + cmd_parts.append(f"--reasoning-effort {args.effort}") + if getattr(args, "permission_profile", None): + cmd_parts.append(f"--permission-profile {args.permission_profile}") + if getattr(args, "trust_workspace", False): + cmd_parts.append("--trust-workspace") + if getattr(args, "approval_mode", None): + cmd_parts.append(f"--approval-mode {args.approval_mode}") + + full_cmd = " && ".join(cmd_parts) + wrapper_content = f"#!/bin/bash\n{full_cmd}\ntouch /tmp/{session}.exit\n" + Path(wrapper_path).write_text(wrapper_content, encoding="utf-8") + os.chmod(wrapper_path, 0o755) + + MUSE_TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True) + log_path = MUSE_TMUX_LOG_DIR / f"{session}.log" + + tmux_cmd = [ + "tmux", "-S", "/tmp/tmux-muse.sock", + "new-session", "-d", "-s", session, + f"{wrapper_path} 2>&1 | tee {log_path}" + ] + res = subprocess.run(tmux_cmd) + if res.returncode != 0: + print(f"Error launching tmux session: {res.stderr or 'failed'}", file=sys.stderr) + sys.exit(res.returncode or 1) + + print(f"session: {session}") + print(f"attach: tmux -S /tmp/tmux-muse.sock attach -t {session}") + + if auto_approve: + watch_session, watch_script = _write_watch_script(session) + watch_log = MUSE_TMUX_LOG_DIR / f"{watch_session}.log" + w_cmd = [ + "tmux", "-S", "/tmp/tmux-muse.sock", + "new-session", "-d", "-s", watch_session, + f"{watch_script} 2>&1 | tee {watch_log}" + ] + w_res = subprocess.run(w_cmd) + if w_res.returncode == 0: + print(f"watcher: {watch_session}") + print(f"watcher-log: {watch_log}") + + +def cmd_watch(args): + """Spawn an auto-approve watcher on an existing tmux session.""" + if not shutil.which("tmux"): + print("tmux not found", file=sys.stderr) + sys.exit(2) + + raw_session = getattr(args, "session", None) + if not raw_session: + print("Error: session name required", file=sys.stderr) + sys.exit(2) + session = _sanitize_tmux_name(raw_session) + + has_res = subprocess.run(["tmux", "-S", "/tmp/tmux-muse.sock", "has-session", "-t", session]) + if has_res.returncode != 0: + print(f"Error: session '{session}' does not exist", file=sys.stderr) + sys.exit(2) + + watch_session, watch_script = _write_watch_script(session) + MUSE_TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True) + watch_log = MUSE_TMUX_LOG_DIR / f"{watch_session}.log" + + w_cmd = [ + "tmux", "-S", "/tmp/tmux-muse.sock", + "new-session", "-d", "-s", watch_session, + f"{watch_script} 2>&1 | tee {watch_log}" + ] + w_res = subprocess.run(w_cmd) + if w_res.returncode != 0: + print(f"Error starting watcher: {w_res.stderr or 'failed'}", file=sys.stderr) + sys.exit(w_res.returncode or 1) + + print(f"watching: {session}") + print(f"watcher: {watch_session}") + print(f"watcher-log: {watch_log}") + + def cmd_docs_dispatch(args): """Bridge 'box docs' and 'super docs' directly to bin/docs-lookup.py.""" d_args = getattr(args, "docs_args", []) or [] @@ -5384,6 +5655,14 @@ def build_parser(): p_rt_launch.add_argument("--dry-run", action="store_true", help="Print the launch plan without creating") p_rt_launch.add_argument("muse_args", nargs=argparse.REMAINDER, default=[], help="Extra muse args after --") + p_rt_layout = rt_sub.add_parser("layout", parents=[common], help="Show pane geometry vs approval minimums") + p_rt_layout.add_argument("--socket", default=None, help="Only this tmux socket") + + p_rt_spread = rt_sub.add_parser("spread", parents=[common], help="Break squeezed runtimes into own windows") + p_rt_spread.add_argument("--socket", default=None, help="Only this tmux socket") + p_rt_spread.add_argument("--session", default=None, help="Only this tmux session") + p_rt_spread.add_argument("--dry-run", action="store_true", help="Print the spread plan without moving panes") + # Domain: INVITE p_invite = subparsers.add_parser("invite", parents=[common], help="Muse.ai invite codes: find per-agent codes and redeem") p_invite.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node (status)")