956 lines
36 KiB
Python
Executable File
956 lines
36 KiB
Python
Executable File
#!/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())
|