Files
box/bin/tmux_auto_approver.py
T

956 lines
36 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""tmux_auto_approver.py — Tmux worker management, worker tallies, and regex auto-approvals.
Supports:
1. Multi-socket and multi-agent discovery:
- Shared host socket: /tmp/tmux-muse.sock
- User sockets: /tmp/tmux-1000/default, /tmp/tmux-1000/lte
- Agent netns sockets: /tmp/tmux-<node>.sock (muse, pip, 646, opm, dev, def)
2. Worker Tally:
- Aggregates active sessions, windows, panes, current commands, PIDs, and runtimes.
3. Regex Auto-Approvals for on-board muse-code runs and autonomous agent workers:
- Muse Code execution prompts ("Would you like to run the following... -> 1")
- A/B/C choice prompts -> "A"
- Numbered menus -> "1"
- y/n confirmation prompts -> "y"
- Interview navigate+select menus (cursor on 1 -> Enter)
- Press Enter prompts -> "Enter"
- Safety guardrails (passwords, passkeys, destructive commands are never auto-approved)
4. State persistence & audit logging:
- Desired state in .state/tmux-auto-approvals.json
- Audit log stream in logs/tmux/auto-approvals.jsonl
5. Surface linking with https://box.muse-dev.online/
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import signal
import subprocess
import sys
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
REPO_ROOT = Path(__file__).resolve().parent.parent
STATE_DIR = REPO_ROOT / ".state"
LOG_DIR = REPO_ROOT / "logs" / "tmux"
STATE_FILE = STATE_DIR / "tmux-auto-approvals.json"
AUDIT_LOG_FILE = LOG_DIR / "auto-approvals.jsonl"
TMUX_BIN = "/home/super/.local/bin/tmux"
if not os.path.exists(TMUX_BIN):
TMUX_BIN = "tmux"
KNOWN_SOCKETS = [
"/tmp/tmux-1000/default",
"/tmp/tmux-1000/lte",
"/tmp/tmux-muse.sock",
"/tmp/tmux-pip.sock",
"/tmp/tmux-646.sock",
"/tmp/tmux-opm.sock",
"/tmp/tmux-dev.sock",
"/tmp/tmux-def.sock",
]
FLEET_AGENTS = ["muse", "pip", "646", "opm", "dev", "def"]
# =====================================================================
# Regex Match Rules for Terminal Prompts
# =====================================================================
@dataclass
class MatchRule:
id: str
name: str
pattern: str
response_key: str
category: str # "muse_code", "choice", "menu", "confirm", "enter"
enabled: bool = True
description: str = ""
press_enter: bool = False # whether response requires trailing Enter
# Default built-in rules
DEFAULT_RULES: List[MatchRule] = [
MatchRule(
id="muse_code_run_numbered",
name="Muse Code Run (Numbered)",
pattern=r"Would you like to run the following[\s\S]*?›\s*1\.\s*Yes,?\s*proceed",
response_key="1",
category="muse_code",
enabled=True,
description="Auto-approves 'Would you like to run the following ... › 1. Yes, proceed (y)'",
press_enter=False,
),
MatchRule(
id="muse_code_run_yn",
name="Muse Code Run (y/n)",
pattern=r"›\s*1\.\s*Yes,?\s*proceed\s*\(y\)",
response_key="1",
category="muse_code",
enabled=True,
description="Matches active selection indicator on '1. Yes, proceed (y)'",
press_enter=False,
),
MatchRule(
id="muse_code_allow_execution",
name="Muse Code Allow Execution",
pattern=r"Allow\s+execution\s+of\b[\s\S]*?\[y/N\]",
response_key="y",
category="muse_code",
enabled=True,
description="Approves 'Allow execution of ... [y/N]'",
press_enter=True,
),
MatchRule(
id="choice_abc",
name="Lettered Choice (A/B/C)",
pattern=r"(?i)(?:choose|choice|select|pick\s+one|enter\s+[A-Z]\b)[\s\S]*?^\s*[A-Z]\s*[.\)\-:]\s+\S",
response_key="A",
category="choice",
enabled=True,
description="Selects choice 'A' on lettered decision prompts",
press_enter=True,
),
MatchRule(
id="menu_numbered",
name="Numbered Menu ((1)/(2))",
pattern=r"(?i)(?:Option:|Selection:|choose|select\s+an?|pick\s+a\s+number)[\s\S]*?^\s*\(?1\)?\s+[A-Za-z]",
response_key="1",
category="menu",
enabled=True,
description="Selects option 1 on numbered choice menus",
press_enter=True,
),
MatchRule(
id="confirm_yn",
name="Line-end y/n Confirmation",
pattern=r"([yY]/[nN]|\[[yY]/[nN]\])\s*[\]:)>]?\s*$",
response_key="y",
category="confirm",
enabled=True,
description="Confirms y/n at end of terminal line",
press_enter=True,
),
MatchRule(
id="interview_select",
name="Interview Menu (cursor on 1)",
pattern=(r"\?\s*\n"
r"(?:[^\n]*\n){0,8}"
r"[ \t]*(?:›|>)[ \t]*1\.[ \t]+\S[^\n]*\n"
r"(?:[^\n]*\n){0,10}"
r"[ \t]*2\.[ \t]+\S"),
response_key="Enter",
category="enter",
enabled=True,
description=("Selects highlighted option 1 on navigate+select "
"menus (cursor on 1. + 2. + ?-question above)"),
press_enter=False,
),
MatchRule(
id="enter_to_continue",
name="Press Enter to Continue",
pattern=r"(?i)(?:Press\s+\[?Enter\]?\s+to\s+continue|hit\s+enter\s+to\s+proceed)",
response_key="Enter",
category="enter",
enabled=True,
description="Sends Enter key on 'Press Enter to continue' prompts",
press_enter=False,
),
]
# Guardrails: NEVER auto-approve these patterns (alert human operator)
GUARDRAIL_PATTERNS = [
re.compile(r"\[sudo\]\s+password\s+for", re.IGNORECASE),
re.compile(r"password\s*:\s*$", re.IGNORECASE),
re.compile(r"(passkey|private\s+key\s+passphrase|Enter\s+PIN)", re.IGNORECASE),
re.compile(r"rm\s+-rf\s+/(?:\s|$)", re.IGNORECASE),
re.compile(r"mkfs\.", re.IGNORECASE),
]
# =====================================================================
# State & Configuration
# =====================================================================
@dataclass
class AutoApproverState:
global_enabled: bool = True
agents_enabled: Dict[str, bool] = field(default_factory=lambda: {a: True for a in FLEET_AGENTS})
sessions_enabled: Dict[str, bool] = field(default_factory=dict)
rules: List[Dict[str, Any]] = field(default_factory=lambda: [asdict(r) for r in DEFAULT_RULES])
max_approvals_per_hour: int = 40
poll_interval: float = 1.0
updated_at: float = field(default_factory=time.time)
def save(self) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
self.updated_at = time.time()
with open(STATE_FILE, "w") as f:
json.dump(asdict(self), f, indent=2)
@classmethod
def load(cls) -> "AutoApproverState":
if not STATE_FILE.exists():
st = cls()
st.save()
return st
try:
with open(STATE_FILE) as f:
data = json.load(f)
st = cls(**data)
except Exception:
return cls()
# Migrate: append built-in rules missing from stored state (a new
# default must reach the daemon without wiping operator toggles).
try:
have = {r.get("id") for r in st.rules
if isinstance(r, dict)}
missing = [asdict(r) for r in DEFAULT_RULES
if r.id not in have]
if missing:
st.rules.extend(missing)
st.save()
except Exception:
pass
return st
# =====================================================================
# Tmux Worker Tally & Inspection
# =====================================================================
@dataclass
class TmuxPaneInfo:
socket: str
session: str
window_idx: int
pane_id: str
pane_pid: int
current_command: str
active: bool
attached: bool
title: str
agent_node: str
auto_approve: bool = True
pending_prompt: Optional[str] = None
matched_rule: Optional[str] = None
@dataclass
class TmuxWorkerTally:
total_sockets: int
total_sessions: int
total_panes: int
active_workers: int
by_agent: Dict[str, Dict[str, Any]]
panes: List[TmuxPaneInfo]
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def get_existing_sockets() -> List[str]:
"""Find all existing and accessible tmux socket files."""
found = []
# Check explicitly known paths
for s in KNOWN_SOCKETS:
if os.path.exists(s):
found.append(s)
# Check /tmp for other tmux-*.sock files
try:
for f in os.listdir("/tmp"):
p = os.path.join("/tmp", f)
if f.startswith("tmux-") and f.endswith(".sock") and p not in found:
found.append(p)
except Exception:
pass
# Check /tmp/tmux-1000/
t1000 = "/tmp/tmux-1000"
if os.path.isdir(t1000):
try:
for f in os.listdir(t1000):
p = os.path.join(t1000, f)
if p not in found:
found.append(p)
except Exception:
pass
return sorted(list(set(found)))
def infer_agent_for_session(socket_path: str, session_name: str) -> str:
"""Determine the owning agent (muse, pip, 646, opm, dev, def, host)."""
s_lower = session_name.lower()
sock_lower = socket_path.lower()
for agent in FLEET_AGENTS:
if f"-{agent}." in sock_lower or f"/{agent}" in sock_lower:
return agent
if s_lower == agent or s_lower.startswith(f"{agent}-") or f"_{agent}_" in s_lower:
return agent
if "muse" in sock_lower or "muse" in s_lower:
return "muse"
return "host"
def run_tmux_cmd(socket_path: str, *args: str, timeout: float = 3.0) -> Tuple[int, str, str]:
"""Execute tmux on a specific socket."""
cmd = [TMUX_BIN, "-S", socket_path] + list(args)
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return res.returncode, res.stdout, res.stderr
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except Exception as e:
return -1, "", str(e)
def capture_pane_text(socket_path: str, pane_id: str, lines: int = 30) -> str:
"""Capture recent lines from a pane, joining wrapped rows.
-J joins physical wrapped lines into logical lines so matching is
width-independent: narrow panes wrap the same dialog onto more
rows, which otherwise breaks cue/option regexes.
"""
rc, out, _ = run_tmux_cmd(socket_path, "capture-pane", "-p", "-J",
"-t", pane_id, "-S", f"-{lines}")
if rc == 0:
return out
return ""
MUSE_COMMAND_HINTS = ("muse-bin", "muse-code")
# (socket, pane) ever observed running a muse runtime. pane_current_command
# flickers to the child tool while the agent works, so a muse pane stays
# muse-owned when its foreground reads "python3" (observed live: the hint
# gate missed tool-running panes and both daemons stacked 'y' answers).
_MUSE_PANES_SEEN = set()
# tmux rule category -> muse watcher kind for verified sends. Text-input
# categories verify render + submit with one retry; single-key widgets
# (and unknown categories) stay blind.
_CATEGORY_KIND_MAP = {
"choice": "letter",
"menu": "numbered",
"confirm": "yn",
"muse_code": "muse-approval",
"enter": None,
}
def should_defer_to_muse_watcher(socket_path: str, pane_id: str,
current_command: str) -> bool:
"""True when a per-pane muse watcher owns this pane.
Single-owner rule: muse_choice_watcher is authoritative for muse
panes (stability + re-verify + once-per-prompt + decided-block
guard). When its daemon is alive for this socket:pane, tmux must
skip the pane entirely, or both daemons answer the same prompt
within the same second ('11' + stray keys, observed live; later the
same hole stacked 'y' answers when the foreground flickered to a
child tool mid-poll). Never raises: import or liveness failures
mean no owner, handle here.
"""
try:
import muse_choice_watcher as mcw
cmd = current_command or ""
key = (socket_path, pane_id)
if any(h in cmd for h in MUSE_COMMAND_HINTS):
_MUSE_PANES_SEEN.add(key)
alive = getattr(mcw, "watcher_alive", mcw.is_running)
return alive(socket_path, pane_id) is not None
if key in _MUSE_PANES_SEEN:
alive = getattr(mcw, "watcher_alive", mcw.is_running)
return alive(socket_path, pane_id) is not None
# Never observed as muse: cheap pidfile check only (covers a
# watcher racing ahead of our first observation of the pane).
return mcw.is_running(socket_path, pane_id) is not None
except Exception:
return False
def gather_tmux_tally(state: Optional[AutoApproverState] = None) -> TmuxWorkerTally:
"""Scan all sockets and build a comprehensive tally of tmux workers."""
if state is None:
state = AutoApproverState.load()
sockets = get_existing_sockets()
all_panes: List[TmuxPaneInfo] = []
agent_stats: Dict[str, Dict[str, Any]] = {
a: {"sessions": 0, "panes": 0, "active_commands": [], "auto_approve": state.agents_enabled.get(a, True)}
for a in FLEET_AGENTS
}
agent_stats["host"] = {"sessions": 0, "panes": 0, "active_commands": [], "auto_approve": state.global_enabled}
total_sessions_set = set()
fmt = "#{session_name}___#{window_index}___#{pane_id}___#{pane_pid}___#{pane_current_command}___#{pane_active}___#{session_attached}___#{pane_title}"
for sock in sockets:
rc, out, err = run_tmux_cmd(sock, "list-panes", "-a", "-F", fmt)
if rc != 0 or not out.strip():
continue
for line in out.strip().splitlines():
parts = line.split("___")
if len(parts) < 8:
continue
sess_name = parts[0]
try:
win_idx = int(parts[1])
p_id = parts[2]
p_pid = int(parts[3])
cmd_name = parts[4]
p_active = (parts[5] == "1")
s_attached = (parts[6] == "1")
p_title = parts[7]
except Exception:
continue
sess_key = f"{sock}:{sess_name}"
total_sessions_set.add(sess_key)
agent = infer_agent_for_session(sock, sess_name)
# Determine auto-approve state
is_auto = (
state.global_enabled
and state.agents_enabled.get(agent, True)
and state.sessions_enabled.get(sess_name, True)
)
pane_info = TmuxPaneInfo(
socket=sock,
session=sess_name,
window_idx=win_idx,
pane_id=p_id,
pane_pid=p_pid,
current_command=cmd_name,
active=p_active,
attached=s_attached,
title=p_title,
agent_node=agent,
auto_approve=is_auto,
)
all_panes.append(pane_info)
# Update stats
if agent in agent_stats:
agent_stats[agent]["panes"] += 1
if cmd_name not in agent_stats[agent]["active_commands"]:
agent_stats[agent]["active_commands"].append(cmd_name)
# Count distinct sessions per agent
for p in all_panes:
agent = p.agent_node
if agent in agent_stats:
agent_stats[agent]["sessions"] = len({x.session for x in all_panes if x.agent_node == agent})
return TmuxWorkerTally(
total_sockets=len(sockets),
total_sessions=len(total_sessions_set),
total_panes=len(all_panes),
active_workers=len([p for p in all_panes if p.current_command not in ("bash", "sh", "zsh", "")]),
by_agent=agent_stats,
panes=all_panes,
)
# =====================================================================
# Regex Matcher Engine
# =====================================================================
@dataclass
class MatchVerdict:
matched: bool
rule_id: Optional[str] = None
rule_name: Optional[str] = None
category: Optional[str] = None
key: Optional[str] = None
press_enter: bool = False
excerpt: Optional[str] = None
reason: Optional[str] = None
is_blocked: bool = False
blocked_reason: Optional[str] = None
class RegexApproverEngine:
"""Evaluates scrollback text against active match rules and guardrails."""
def __init__(self, rules: Optional[List[MatchRule]] = None):
if rules is None:
self.rules = list(DEFAULT_RULES)
else:
self.rules = rules
self._compiled_rules = [(r, re.compile(r.pattern, re.MULTILINE)) for r in self.rules if r.enabled]
def reload(self, rules: List[MatchRule]) -> None:
self.rules = rules
self._compiled_rules = [(r, re.compile(r.pattern, re.MULTILINE)) for r in self.rules if r.enabled]
def evaluate(self, text: str, tail_lines: int = 35) -> MatchVerdict:
"""Evaluate terminal text and return match verdict."""
if not text:
return MatchVerdict(matched=False, reason="Empty text")
lines = text.strip().splitlines()
tail_text = "\n".join(lines[-tail_lines:])
# 1. Guardrail safety check (NEVER auto-approve sudo/passwords)
for guard in GUARDRAIL_PATTERNS:
m = guard.search(tail_text)
if m:
return MatchVerdict(
matched=False,
is_blocked=True,
blocked_reason=f"Security guardrail triggered: '{m.group(0)}'",
excerpt=m.group(0),
)
# 2. Test active rules in priority order
for rule, compiled in self._compiled_rules:
m = compiled.search(tail_text)
if m:
excerpt = m.group(0)
if len(excerpt) > 100:
excerpt = excerpt[:100] + "..."
return MatchVerdict(
matched=True,
rule_id=rule.id,
rule_name=rule.name,
category=rule.category,
key=rule.response_key,
press_enter=rule.press_enter,
excerpt=excerpt,
reason=f"Matched rule '{rule.name}'",
)
return MatchVerdict(matched=False, reason="No matching prompt found in tail window")
# =====================================================================
# Auto-Approval Executor & Daemon Loop
# =====================================================================
class AutoApproverRunner:
"""Monitors tmux panes, applies regex matching, and dispatches keys."""
def __init__(self, dry_run: bool = False):
self.dry_run = dry_run
self.state = AutoApproverState.load()
rule_objs = [MatchRule(**r) for r in self.state.rules]
self.engine = RegexApproverEngine(rule_objs)
self.recent_signatures: Dict[str, Tuple[float, str]] = {}
self.approval_counts: List[float] = []
def record_audit(self, event: Dict[str, Any]) -> None:
"""Write structured audit log event."""
try:
LOG_DIR.mkdir(parents=True, exist_ok=True)
event["timestamp"] = datetime.now(timezone.utc).isoformat()
event["ts"] = time.time()
with open(AUDIT_LOG_FILE, "a") as f:
f.write(json.dumps(event) + "\n")
except Exception:
pass
def check_rate_limit(self) -> bool:
"""Enforce hourly approval backstop."""
now = time.time()
self.approval_counts = [t for t in self.approval_counts if now - t < 3600]
return len(self.approval_counts) < self.state.max_approvals_per_hour
def run_once(self) -> List[Dict[str, Any]]:
"""Scan all panes once and dispatch auto-approvals for any matched prompts."""
self.state = AutoApproverState.load()
if not self.state.global_enabled:
return [{"status": "disabled", "message": "Global auto-approvals are DISABLED"}]
rule_objs = [MatchRule(**r) for r in self.state.rules]
self.engine.reload(rule_objs)
tally = gather_tmux_tally(self.state)
actions_taken = []
for p in tally.panes:
if not p.auto_approve:
continue
if should_defer_to_muse_watcher(p.socket, p.pane_id,
p.current_command):
continue
text = capture_pane_text(p.socket, p.pane_id, lines=30)
if not text:
continue
verdict = self.engine.evaluate(text)
if verdict.is_blocked:
self.record_audit({
"action": "BLOCKED",
"socket": p.socket,
"pane": p.pane_id,
"session": p.session,
"agent": p.agent_node,
"reason": verdict.blocked_reason,
"excerpt": verdict.excerpt,
})
continue
if verdict.matched and verdict.key:
# Deduplicate identical prompt to avoid infinite loop.
# Keyed by socket:pane: bare pane ids repeat on every
# tmux socket, so %1 on pip must not suppress %1 on opm.
sig = hashlib.sha1(f"{verdict.rule_id}:{verdict.excerpt}".encode()).hexdigest()
dedup_key = "%s:%s" % (p.socket, p.pane_id)
last_time, last_sig = self.recent_signatures.get(dedup_key, (0, ""))
if last_sig == sig and (time.time() - last_time) < 15.0:
continue # already handled recently
if not self.check_rate_limit():
actions_taken.append({
"pane": p.pane_id,
"session": p.session,
"status": "rate_limited",
"rule": verdict.rule_name,
})
continue
# Execute key dispatch through the verified send path:
# literal text paced apart from Enter (a single-call
# burst arrives as paste and lands a newline in
# composers instead of submitting, then re-fires past
# dedup and stacks). Text-input categories also verify
# render + submit with one retry; single-key widgets
# stay blind.
success = False
detail = {"verified": None, "retried": False}
if not self.dry_run:
import muse_choice_watcher as mcw
want_enter = (verdict.key != "Enter"
and bool(verdict.press_enter))
ok, detail = mcw.send_answer(
p.socket, p.pane_id, verdict.key,
enter=want_enter,
kind=_CATEGORY_KIND_MAP.get(verdict.category),
sig=sig)
success = bool(ok)
else:
success = True # dry-run simulated
now = time.time()
self.recent_signatures[dedup_key] = (now, sig)
self.approval_counts.append(now)
event = {
"action": "AUTO_APPROVED" if not self.dry_run else "DRY_RUN_MATCH",
"socket": p.socket,
"pane": p.pane_id,
"session": p.session,
"agent": p.agent_node,
"rule_id": verdict.rule_id,
"rule_name": verdict.rule_name,
"key_sent": verdict.key,
"press_enter": verdict.press_enter,
"excerpt": verdict.excerpt,
"dry_run": self.dry_run,
"success": success,
"verified": detail["verified"],
"retried": detail["retried"],
}
self.record_audit(event)
actions_taken.append(event)
return actions_taken
def watch_loop(self, interval: Optional[float] = None) -> None:
"""Run continuous monitoring loop."""
if interval is None:
interval = self.state.poll_interval
print(f"[*] Tmux Auto-Approver watching across sockets (interval: {interval}s, dry_run: {self.dry_run})...")
print(f"[*] Audit log: {AUDIT_LOG_FILE}")
sys.stdout.flush()
while True:
try:
res = self.run_once()
for act in res:
if act.get("action") in ("AUTO_APPROVED", "DRY_RUN_MATCH"):
print(f"[{datetime.now().strftime('%H:%M:%S')}] ✔ {act['action']} on {act['agent'].upper()}:{act['session']} ({act['pane']}) -> sent '{act['key_sent']}' for '{act['rule_name']}'")
sys.stdout.flush()
time.sleep(interval)
except KeyboardInterrupt:
print("\n[*] Exiting watch loop.")
break
except Exception as e:
time.sleep(interval)
# =====================================================================
# CLI Command Implementations
# =====================================================================
def cmd_tally(args: argparse.Namespace) -> int:
tally = gather_tmux_tally()
if getattr(args, "json", False):
print(json.dumps(asdict(tally), indent=2))
return 0
print("══════════════════════════════════════════════════════════════════════════════")
print(f" TMUX WORKER TALLY — {tally.total_sessions} Sessions · {tally.total_panes} Panes · {tally.active_workers} Active Workers across {tally.total_sockets} Sockets")
print("══════════════════════════════════════════════════════════════════════════════")
# Agent breakdown table
print("\nAGENT WORKERS SUMMARY:")
print(f" {'Agent':<8} {'Sessions':<10} {'Panes':<8} {'Auto-Approve':<14} {'Active Commands'}")
print(" " + "─" * 70)
for agent, info in tally.by_agent.items():
auto_str = "ENABLED [●]" if info.get("auto_approve") else "DISABLED [○]"
cmds_str = ", ".join(info.get("active_commands", [])) or "(idle bash)"
print(f" {agent:<8} {info.get('sessions', 0):<10} {info.get('panes', 0):<8} {auto_str:<14} {cmds_str}")
# Detailed Pane Table
print("\nACTIVE PANES & WORKERS:")
print(f" {'Socket':<22} {'Session':<14} {'Pane':<6} {'PID':<8} {'Agent':<6} {'Cmd':<16} {'Auto':<6}")
print(" " + "─" * 82)
for p in tally.panes:
sock_short = os.path.basename(p.socket)
auto_tag = "YES" if p.auto_approve else "NO"
print(f" {sock_short:<22} {p.session[:13]:<14} {p.pane_id:<6} {p.pane_pid:<8} {p.agent_node:<6} {p.current_command[:15]:<16} {auto_tag:<6}")
print("")
return 0
def cmd_status(args: argparse.Namespace) -> int:
st = AutoApproverState.load()
if getattr(args, "json", False):
print(json.dumps(asdict(st), indent=2))
return 0
print("══════════════════════════════════════════════════════════════════")
print(" TMUX AUTO-APPROVAL RUNTIME STATUS")
print("══════════════════════════════════════════════════════════════════")
status_badge = "ENABLED [●]" if st.global_enabled else "DISABLED [○]"
print(f" Master State: {status_badge}")
print(f" Max Approvals / Hour: {st.max_approvals_per_hour}")
print(f" Poll Interval: {st.poll_interval}s")
print(f" Audit Log: {AUDIT_LOG_FILE}")
print(f" Surface Link: https://box.muse-dev.online/")
print("\nPER-AGENT AUTO-APPROVE POLICIES:")
for a in FLEET_AGENTS:
en = st.agents_enabled.get(a, True)
badge = "ON [✔]" if en else "OFF [✖]"
print(f" • {a:<6}: {badge}")
print(f"\nACTIVE REGEX RULES ({len(st.rules)}):")
for r in st.rules:
en_str = "ON" if r.get("enabled") else "OFF"
print(f" [{en_str}] {r.get('id'):<25} -> sends '{r.get('response_key')}' ({r.get('category')})")
print("")
return 0
def cmd_toggle_on(args: argparse.Namespace) -> int:
st = AutoApproverState.load()
target_node = getattr(args, "node", None)
target_session = getattr(args, "session", None)
if target_node:
st.agents_enabled[target_node] = True
print(f"✔ Enabled auto-approvals for agent: {target_node.upper()}")
elif target_session:
st.sessions_enabled[target_session] = True
print(f"✔ Enabled auto-approvals for session: '{target_session}'")
else:
st.global_enabled = True
for a in FLEET_AGENTS:
st.agents_enabled[a] = True
print("✔ Enabled master auto-approvals across all fleet agents & sessions.")
st.save()
return 0
def cmd_toggle_off(args: argparse.Namespace) -> int:
st = AutoApproverState.load()
target_node = getattr(args, "node", None)
target_session = getattr(args, "session", None)
if target_node:
st.agents_enabled[target_node] = False
print(f"✖ Disabled auto-approvals for agent: {target_node.upper()}")
elif target_session:
st.sessions_enabled[target_session] = False
print(f"✖ Disabled auto-approvals for session: '{target_session}'")
else:
st.global_enabled = False
print("✖ Disabled master auto-approvals globally.")
st.save()
return 0
def cmd_match_test(args: argparse.Namespace) -> int:
text = args.text
if text == "-" or not text:
text = sys.stdin.read()
engine = RegexApproverEngine()
verdict = engine.evaluate(text)
out = {
"matched": verdict.matched,
"rule_id": verdict.rule_id,
"rule_name": verdict.rule_name,
"category": verdict.category,
"key_to_send": verdict.key,
"press_enter": verdict.press_enter,
"excerpt": verdict.excerpt,
"reason": verdict.reason,
"is_blocked": verdict.is_blocked,
"blocked_reason": verdict.blocked_reason,
}
print(json.dumps(out, indent=2))
return 0 if verdict.matched else 1
def cmd_run_once(args: argparse.Namespace) -> int:
runner = AutoApproverRunner(dry_run=getattr(args, "dry_run", False))
res = runner.run_once()
print(json.dumps(res, indent=2))
return 0
def cmd_watch(args: argparse.Namespace) -> int:
runner = AutoApproverRunner(dry_run=getattr(args, "dry_run", False))
interval = getattr(args, "interval", 1.0)
runner.watch_loop(interval=interval)
return 0
def cmd_logs(args: argparse.Namespace) -> int:
lines = getattr(args, "lines", 20) or 20
if not AUDIT_LOG_FILE.exists():
print("No auto-approval logs yet.")
return 0
with open(AUDIT_LOG_FILE) as f:
all_lines = f.readlines()
tail = all_lines[-lines:]
for l in tail:
try:
d = json.loads(l)
ts = d.get("timestamp", "")[:19].replace("T", " ")
act = d.get("action", "")
ag = d.get("agent", "")
sess = d.get("session", "")
pane = d.get("pane", "")
key = d.get("key_sent", "")
rule = d.get("rule_name", "")
print(f"[{ts}] {act:<14} {ag.upper():<6} {sess:<12} ({pane}) -> sent '{key}' [{rule}]")
except Exception:
print(l.strip())
return 0
def cmd_spawn_worker(args: argparse.Namespace) -> int:
session = args.session
cmd = getattr(args, "command", "bash")
node = getattr(args, "node", "muse")
sock = f"/tmp/tmux-{node}.sock" if node != "muse" else "/tmp/tmux-muse.sock"
# Spawn session
t_cmd = [TMUX_BIN, "-S", sock, "new-session", "-d", "-s", session, cmd]
res = subprocess.run(t_cmd, capture_output=True, text=True)
if res.returncode == 0:
print(f"✔ Successfully spawned worker '{session}' on {sock} running '{cmd}'")
return 0
else:
print(f"Failed to spawn worker: {res.stderr.strip() or res.stdout.strip()}", file=sys.stderr)
return res.returncode
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Tmux Worker Tally & Regex Auto-Approval Runtime")
subparsers = parser.add_subparsers(dest="subcommand")
# tally
p_tally = subparsers.add_parser("tally", help="Tally all tmux sessions, workers, and panes")
p_tally.add_argument("--json", action="store_true", help="Output machine-readable JSON")
p_tally.set_defaults(func=cmd_tally)
# status
p_status = subparsers.add_parser("status", help="Show auto-approval configuration & policies")
p_status.add_argument("--json", action="store_true", help="Output machine-readable JSON")
p_status.set_defaults(func=cmd_status)
# on / off
p_on = subparsers.add_parser("on", help="Enable auto-approvals (global, per-agent, or per-session)")
p_on.add_argument("--node", choices=FLEET_AGENTS, help="Enable for specific agent")
p_on.add_argument("--session", help="Enable for specific session name")
p_on.set_defaults(func=cmd_toggle_on)
p_off = subparsers.add_parser("off", help="Disable auto-approvals")
p_off.add_argument("--node", choices=FLEET_AGENTS, help="Disable for specific agent")
p_off.add_argument("--session", help="Disable for specific session name")
p_off.set_defaults(func=cmd_toggle_off)
# match
p_match = subparsers.add_parser("match", help="Test regex match against scrollback text")
p_match.add_argument("text", nargs="?", default="-", help="Input text or '-' for stdin")
p_match.set_defaults(func=cmd_match_test)
# once
p_once = subparsers.add_parser("once", help="Evaluate and auto-approve all active prompts right now")
p_once.add_argument("--dry-run", action="store_true", help="Log matches without sending keys")
p_once.set_defaults(func=cmd_run_once)
# watch
p_watch = subparsers.add_parser("watch", help="Run background monitor daemon for auto-approvals")
p_watch.add_argument("--interval", type=float, default=1.0, help="Poll interval in seconds (default: 1.0)")
p_watch.add_argument("--dry-run", action="store_true", help="Log matches without sending keys")
p_watch.set_defaults(func=cmd_watch)
# logs
p_logs = subparsers.add_parser("logs", help="Tail auto-approval audit log stream")
p_logs.add_argument("-n", "--lines", type=int, default=20, help="Number of lines to show")
p_logs.set_defaults(func=cmd_logs)
# spawn
p_spawn = subparsers.add_parser("spawn", help="Spawn a new tmux worker runner")
p_spawn.add_argument("session", help="Session name")
p_spawn.add_argument("--command", "-c", default="bash", help="Command to run")
p_spawn.add_argument("--node", choices=FLEET_AGENTS, default="muse", help="Target agent socket")
p_spawn.set_defaults(func=cmd_spawn_worker)
return parser
def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
if argv is None:
argv = sys.argv[1:]
if not argv:
parser.print_help()
return 0
args = parser.parse_args(argv)
if not hasattr(args, "func"):
parser.print_help()
return 1
return args.func(args)
if __name__ == "__main__":
sys.exit(main())