#!/usr/bin/env python3 """muse_choice_watcher.py — Per-pane daemon auto-approving Muse prompts. Kinds (prompt -> key): native Muse approval menu -> "1", agent interview menu -> "1", explicit-phrase request -> captured TOKEN, lettered A/B/C choice -> "A", numbered (1)/(2) menu -> "1", y/n line-end prompt -> "y". Each prompt is answered at most once (after stability + re-verify), with an hourly cap as backstop. Design notes (from the agy watcher post-mortem): * State is namespaced by (socket, pane): pidfiles and logs embed a socket slug, so identical pane ids on different tmux sockets (e.g. %0 on both `default` and `lte`) never collide. * Logging never kills the loop: every log call and every poll iteration is exception-guarded, so auto-answers continue even when logging or tmux hiccups. Log rotation is best-effort. * Answers are once-per-prompt: a prompt signature must be stable across N polls, is answered at most once, and re-answering requires the prompt to disappear and a new signature to appear. An hourly cap bounds runaways. Usage: muse_choice_watcher.py start --socket /tmp/tmux-1000/default --pane %37 muse_choice_watcher.py start-all [--dry-run] muse_choice_watcher.py status muse_choice_watcher.py stop --socket ... --pane ... muse_choice_watcher.py match < pane.txt # print JSON verdict for tuning """ import argparse import hashlib import json import os import re import signal import subprocess import sys import time from datetime import datetime, timezone STATE_DIR = "/tmp" FILE_PREFIX = "muse-choice-watcher" REPO_ROOT = "/home/super/Projects/NetVM" DESIRED_STATE_FILE = os.path.join(REPO_ROOT, ".state", "muse-choices.json") CTL_LOG = os.path.join(REPO_ROOT, "box-ctl.jsonl") KNOWN_SOCKETS = [ "/tmp/tmux-1000/default", "/tmp/tmux-1000/lte", "/tmp/tmux-muse.sock", ] POLL_INTERVAL = 0.5 STABILITY_POLLS = 2 MAX_ANSWERS_PER_HOUR = 20 ANSWERED_TTL_SECONDS = 600 # identical prompt back after 10m => stuck, allow one recovery answer LOG_MAX_BYTES = 1_000_000 HEARTBEAT_SECONDS = 60 TAIL_WINDOW = 25 # only prompts in the last N lines count (no stale scrollback) OPTION_SPAN = 12 # A..C option lines must fit within N lines (wrapped lines ok) MAX_OPTION_LEN = 160 # option lines longer than this are ignored (prose guard) # A. text / B) text / C: text / C - text (single capital letter + delimiter) OPTION_RE = re.compile(r"^\s*([A-Z])\s*[.\)\-:]\s+\S") # Cue that the lettered list is a choice awaiting reply (not prose). CUE_RE = re.compile( r"(choose|choice|choices|select|option|options|reply|feedback|" r"which one|pick one|enter\s+[A-Z]\b|type\s+[A-Z]\b|press\s+[A-Z]\b|" r"A\s*/\s*B|A\s*,\s*B|A-C|A,B,C|\(A\))", re.IGNORECASE, ) QUESTION_RE = re.compile(r"\?\s*$") # y/n token must END the line: prompts awaiting input put the options last # ("Proceed? (y/n)", "Overwrite? [y/N]"). Mid-line mentions are prose. YN_RE = re.compile(r"([yY]/[nN]|\[[yY]/[nN]\])\s*[\]:)>]?\s*$") YN_WINDOW = 6 # y/n prompt must sit in the last N content lines # Numbered menus: (1) text / (2) text with a selection cue nearby. NUMBERED_OPT_RE = re.compile(r"^\s*\((\d+)\)\s+\S") NUMBERED_CUE_RE = re.compile( r"(Option:|Selection:|choose|choice|select an? |enter (a )?number|pick a number)", re.IGNORECASE, ) # Native Muse approval dialog patterns live in MUSE_APPROVAL_*_RE below # (single definition; the dialog shape is asserted by TestMuseApproval).# Native Muse TUI approval menu (the live auto-approve target): # Would you like to run the following # $ # > 1. Yes, proceed (y) # 2. No, and tell Muse Code what to ... # The cue carries no trailing "?", and the cursor may render as an ascii # ">" or the single right-pointing angle quote (U+203A, escaped below). MUSE_APPROVAL_CUE_RE = re.compile(r"^\s*Would you like to\b", re.IGNORECASE) MUSE_APPROVAL_YES_RE = re.compile("^\\s*[>\\u203a]?\\s*1\\.\\s+Yes\\b") MUSE_APPROVAL_NO_RE = re.compile("^\\s*[>\\u203a]?\\s*2\\.\\s+No\\b") # A decided block ("approval decision accepted/denied") must never match: # answering it would inject stray keys after the decision already landed. MUSE_APPROVAL_DECIDED_RE = re.compile(r"approval decision", re.IGNORECASE) MUSE_APPROVAL_SPAN = 16 # cue..options span (wrapped command echo ok) # Collapsed approval: long commands collapse, hiding the 1.Yes/2.No pair: # Would you like to run the following # $ # ... N command rows omitted # ctrl+o view full command MUSE_COLLAPSED_ROWS_RE = re.compile(r"rows?\s+omitted", re.IGNORECASE) MUSE_COLLAPSED_KEY_RE = re.compile(r"ctrl\s*\+\s*o\b", re.IGNORECASE) # Agent interview UI (request_user_input rendered in-pane; observed live): # # > 1. First option (Recommended) # 2. Second option # 3. Both ... # 4. None of the above ... # The `>`/`›` cursor on an option line proves a live selectable menu # (prose lists never carry it); the question above ends with "?". INTERVIEW_OPT_RE = re.compile("^\\s*([>\\u203a])?\\s*(\\d+)\\.\\s+\\S") INTERVIEW_CUE_ABOVE = 8 # question must sit within N lines above option 1 # Explicit-phrase request (model asks the user to reply a magic word; # observed live): "Reply ACCEPT to approve this text as written ..." # Only a single ALL-CAPS token qualifies -- lowercase/prose after Reply # never matches, which keeps quoted instructions and chat from firing. EXPLICIT_RE = re.compile(r"^\s*(?i:Reply)\s+([A-Z][A-Z0-9_-]{1,11})\b") EXPLICIT_WINDOW = 8 # request must sit in the last N content lines # Runtime state sensing for external agents driving panes via send-keys. # A working pane shows a running indicator ("- running (Ns ...", "Calling # tools (...", or the "esc to interrupt" tail, often wrapped/edge-cut); # an idle pane sits at the prompt glyph (U+276F, escaped below). RUNNING_RE = re.compile("([\\u2014-] running \\(|Calling tools \\(|esc to\\b)") OPEN_PROMPT_RE = re.compile("^\\s*\\u276f") STATE_TAIL_WINDOW = 12 # state cues must sit in the last N content lines def slug_socket(socket_path): """Short filesystem-safe id for a tmux socket: basename + hash suffix. The hash suffix guards against two different socket paths sharing a basename (e.g. /tmp/a/default vs /tmp/b/default). """ base = os.path.basename(socket_path.rstrip("/")) or "sock" safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", base)[:32] digest = hashlib.sha1(socket_path.encode()).hexdigest()[:6] return "%s-%s" % (safe, digest) def clean_pane(pane_id): return re.sub(r"[^A-Za-z0-9]+", "", pane_id) def pidfile_for(socket_path, pane_id): return os.path.join( STATE_DIR, "%s-%s-%s.pid" % (FILE_PREFIX, slug_socket(socket_path), clean_pane(pane_id)) ) def logfile_for(socket_path, pane_id): return os.path.join( STATE_DIR, "%s-%s-%s.log" % (FILE_PREFIX, slug_socket(socket_path), clean_pane(pane_id)) ) class WatcherLog: """Never-raising JSON-lines logger with best-effort rotation.""" def __init__(self, path): self.path = path def _rotate(self): try: if os.path.exists(self.path) and os.path.getsize(self.path) >= LOG_MAX_BYTES: try: if os.path.exists(self.path + ".1"): os.remove(self.path + ".1") except OSError: pass os.rename(self.path, self.path + ".1") except Exception: pass def log(self, level, msg, **fields): try: self._rotate() rec = { "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "level": level, "msg": msg, } rec.update(fields) with open(self.path, "a") as f: f.write(json.dumps(rec) + "\n") except Exception: pass def _audit_local(action, name, caller, extra): """Append a box-ctl.jsonl audit record without importing approvals.""" rec = { "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "action": action, "type": "muse-choice", "name": name, "caller": caller, } if extra: rec.update(extra) with open(CTL_LOG, "a") as f: f.write(json.dumps(rec) + "\n") def audit(action, name=None, caller="muse-choice-watcher", extra=None): """Feed an event to box (box-ctl.jsonl). Never raises. Prefers approvals.log_box_ctl so the audit schema stays unified; falls back to a direct append if the import fails. """ try: try: import approvals approvals.log_box_ctl(action, name=name, caller=caller, extra=extra) except Exception: _audit_local(action, name, caller, extra or {}) except Exception: pass def get_desired(): """Desired daemon state: {"enabled", "dry_run", ...}. Default: on. Policy: auto-approve is on unless explicitly disabled (`box muse-choices off`), the pane opted out via launch flags, or a human intervened. A missing/unreadable state file (or a file without the key) means undefined -> on. """ default = {"enabled": True, "dry_run": False} try: with open(DESIRED_STATE_FILE) as f: data = json.load(f) return {"enabled": bool(data.get("enabled", True)), "dry_run": bool(data.get("dry_run", False)), "updated_at": data.get("updated_at", ""), "updated_by": data.get("updated_by", "")} except Exception: return dict(default) def set_enabled(enabled, dry_run=None, by="box"): """Write desired state + audit the transition. Returns the new state.""" state = get_desired() state["enabled"] = bool(enabled) if dry_run is not None: state["dry_run"] = bool(dry_run) state["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") state["updated_by"] = by try: parent = os.path.dirname(DESIRED_STATE_FILE) if parent: os.makedirs(parent, exist_ok=True) tmp = DESIRED_STATE_FILE + ".tmp.%d" % os.getpid() with open(tmp, "w") as f: json.dump(state, f, indent=1) os.replace(tmp, DESIRED_STATE_FILE) except Exception: pass audit("muse-choice-enabled" if enabled else "muse-choice-disabled", caller=by, extra={"dry_run": state["dry_run"]}) return state def _option_markers(lines): """Return [(line_index, letter)] for lettered-option lines.""" out = [] for i, line in enumerate(lines): if len(line) > MAX_OPTION_LEN: continue m = OPTION_RE.match(line) if m: out.append((i, m.group(1))) return out def _ordered_run(markers): """Find A,B[,C...] in order. Returns marker sub-list or None. Markers between run members (wrapped text has no markers, so any interleaving lettered line breaks the run) must fit OPTION_SPAN. """ want = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" best = [] for start in range(len(markers)): if markers[start][1] != "A": continue run = [markers[start]] for idx in range(start + 1, len(markers)): expected = want[len(run)] if markers[idx][1] == expected: run.append(markers[idx]) else: break if len(run) >= 2 and run[-1][0] - run[0][0] <= OPTION_SPAN: if len(run) > len(best): best = run return best or None def _sig_for(kind, parts): src = kind + "\n" + "\n".join(parts) return hashlib.sha1(src.encode()).hexdigest()[:16] def _find_letter(window): """A/B/C lettered choice block -> {"sig", "kind", "key", ...} or None.""" run = _ordered_run(_option_markers(window)) if not run: return None start, end = run[0][0], run[-1][0] lo = max(0, start - 4) hi = min(len(window), end + 5) cue = None for i in range(lo, hi): if CUE_RE.search(window[i]) or QUESTION_RE.search(window[i]): cue = window[i].strip() break if cue is None: return None options = [window[i].strip()[:120] for i, _ in run] cue = cue[:200] return {"sig": _sig_for("letter", options + [cue]), "kind": "letter", "key": "A", "options": options, "cue": cue, "start": start, "end": end} def _find_numbered(window): """(1)/(2) numbered menu with selection cue -> match or None.""" markers = [] for i, line in enumerate(window): if len(line) > MAX_OPTION_LEN: continue m = NUMBERED_OPT_RE.match(line) if m: markers.append((i, int(m.group(1)))) ones = [i for i, n in markers if n == 1] twos = [i for i, n in markers if n == 2] if not ones or not twos: return None start = ones[0] end = next((i for i in twos if i > start), None) if end is None or end - start > OPTION_SPAN: return None lo = max(0, start - 2) hi = min(len(window), end + 4) cue = None for i in range(lo, hi): if NUMBERED_CUE_RE.search(window[i]): cue = window[i].strip() break if cue is None: return None options = [window[i].strip()[:120] for i, n in markers if start <= i <= end] cue = cue[:200] return {"sig": _sig_for("numbered", options + [cue]), "kind": "numbered", "key": "1", "options": options, "cue": cue, "start": start, "end": end} def _find_yn(window): """y/n prompt at the end of a recent line -> match or None.""" recent = window[-YN_WINDOW:] base = len(window) - len(recent) for j in range(len(recent) - 1, -1, -1): line = recent[j] if len(line) > MAX_OPTION_LEN: continue if YN_RE.search(line): text = line.strip()[:200] ctx = recent[j - 1].strip()[:120] if j > 0 else "" i = base + j return {"sig": _sig_for("yn", [text, ctx]), "kind": "yn", "key": "y", "options": [text], "cue": text, "start": i, "end": i} return None def _find_muse_approval(window): """Native Muse TUI approval menu -> match or None. Requires the Would-you-like cue plus a 1.Yes/2.No pair below it. A decided block ("approval decision ...") within the span never matches, so an already-landed decision is never double-answered. """ cue_idx = None for i, line in enumerate(window): if len(line) > MAX_OPTION_LEN: continue if MUSE_APPROVAL_CUE_RE.match(line): cue_idx = i break if cue_idx is None: return None yes_idx = no_idx = None for i in range(cue_idx + 1, min(len(window), cue_idx + 1 + MUSE_APPROVAL_SPAN)): line = window[i] if len(line) > MAX_OPTION_LEN: continue if yes_idx is None and MUSE_APPROVAL_YES_RE.match(line): yes_idx = i elif MUSE_APPROVAL_NO_RE.match(line): no_idx = i break if yes_idx is None or no_idx is None: return None for line in window[cue_idx:no_idx + 4]: if MUSE_APPROVAL_DECIDED_RE.search(line): return None cue = window[cue_idx].strip()[:200] options = [window[yes_idx].strip()[:120], window[no_idx].strip()[:120]] # Sig covers the WHOLE block (cue + $ command + options): bare # options+cue are byte-identical across every command approval, which # used to collapse all of a pane's approvals into one sig and wedge # every dialog after the first (observed live 2026-10-06). context = [ln.strip()[:120] for ln in window[cue_idx:no_idx + 1]] return {"sig": _sig_for("muse-approval", context), "kind": "muse-approval", "key": "1", "options": options, "cue": cue, "start": cue_idx, "end": no_idx} def _find_collapsed_approval(window): """Collapsed approval menu (long command, options hidden) or None. Requires the Would-you-like cue plus the collapsed markers ("N command rows omitted" + "ctrl+o view full command"). Answers with a bare Enter: that expands the block to the full 1/2 form (or accepts outright), and the expanded form answers as a fresh muse-approval prompt on later polls. (ctrl+o is NOT used: it opens a modal pager that strands the session.) """ cue_idx = None for i, line in enumerate(window): if len(line) > MAX_OPTION_LEN: continue if MUSE_APPROVAL_CUE_RE.match(line): cue_idx = i break if cue_idx is None: return None rows_idx = key_idx = None for i in range(cue_idx + 1, min(len(window), cue_idx + 1 + MUSE_APPROVAL_SPAN)): line = window[i] if len(line) > MAX_OPTION_LEN: continue if rows_idx is None and MUSE_COLLAPSED_ROWS_RE.search(line): rows_idx = i if MUSE_COLLAPSED_KEY_RE.search(line): key_idx = i if rows_idx is None or key_idx is None: return None end = max(rows_idx, key_idx) for line in window[cue_idx:end + 4]: if MUSE_APPROVAL_DECIDED_RE.search(line): return None cue = window[cue_idx].strip()[:200] options = [window[rows_idx].strip()[:120], window[key_idx].strip()[:120]] context = [ln.strip()[:120] for ln in window[cue_idx:end + 1]] return {"sig": _sig_for("muse-approval-collapsed", context), "kind": "muse-approval-collapsed", "key": "Enter", "enter": False, "options": options, "cue": cue, "start": cue_idx, "end": end} def _find_interview(window): """Agent interview menu (cursor + ordered 1./2./.. + question) or None. Requires an ordered run starting at 1 with at least options 1 and 2, a selection cursor on one of the run's lines, and a "?"-ended question above. The cursor requirement is what separates a live interview from a prose numbered list. """ markers = [] for i, line in enumerate(window): if len(line) > MAX_OPTION_LEN: continue m = INTERVIEW_OPT_RE.match(line) if m: markers.append((i, int(m.group(2)), bool(m.group(1)))) ones = [i for i, n, c in markers if n == 1] if not ones: return None start = ones[0] run = [start] for i, n, c in markers: if i > start and n == len(run) + 1 and i - start <= OPTION_SPAN: run.append(i) if len(run) < 2: return None if not any(c for i, n, c in markers if run[0] <= i <= run[-1]): return None question = None for j in range(max(0, start - INTERVIEW_CUE_ABOVE), start): if QUESTION_RE.search(window[j]): question = window[j].strip() if question is None: return None options = [window[i].strip()[:120] for i in run] question = question[:200] return {"sig": _sig_for("interview", options + [question]), "kind": "interview", "key": "1", "options": options, "cue": question, "start": start, "end": run[-1]} def _find_explicit(window): """Explicit-phrase request ("Reply ACCEPT to ...") or None. Scans the last EXPLICIT_WINDOW content lines bottom-up so the freshest request wins. The key is the captured token itself. """ recent = window[-EXPLICIT_WINDOW:] base = len(window) - len(recent) for j in range(len(recent) - 1, -1, -1): line = recent[j] if len(line) > MAX_OPTION_LEN: continue m = EXPLICIT_RE.match(line) if m: token = m.group(1) text = line.strip()[:200] i = base + j return {"sig": _sig_for("explicit-phrase", [text]), "kind": "explicit-phrase", "key": token, "options": [text], "cue": text, "start": i, "end": i} return None def find_choice_prompt(text, tail_window=TAIL_WINDOW): """Detect a Muse prompt awaiting reply. Kinds (priority order): muse-approval (native Would-you-like menu -> key "1"), muse-approval-collapsed (collapsed long command -> bare Enter, expand-or-accept), interview (cursor + ordered 1./2. menu + question -> key "1"), explicit-phrase ("Reply TOKEN to ..." -> key TOKEN), letter (A/B/C -> key "A"), numbered ((1)/(2) menu -> key "1"), yn (y/n line-end -> key "y"). Returns {"sig", "kind", "key", "options", "cue", "start", "end"} or None. Only blocks in the last `tail_window` content lines are eligible, so answered/stale prompts in scrollback never re-trigger. """ if not text: return None lines = text.split("\n") # Trailing blank lines (tall panes, fresh prompts) must not push a live # prompt out of the window; staleness is judged on content lines. while lines and not lines[-1].strip(): lines.pop() window = lines[-tail_window:] if not window: return None return (_find_muse_approval(window) or _find_collapsed_approval(window) or _find_interview(window) or _find_explicit(window) or _find_letter(window) or _find_numbered(window) or _find_yn(window)) def runtime_state(text): """Classify a Muse pane capture for agentic send-keys drivers. Returns {"state", "match"} where state is one of approval-pending (a choice prompt awaits reply), working (running indicator), open-prompt (idle at the input prompt), or unknown. Priority is approval-pending > working > open-prompt: a working pane still renders its prompt footer, and an approval can arrive mid-run. """ match = find_choice_prompt(text) if match is not None: return {"state": "approval-pending", "match": match} lines = (text or "").split("\n") while lines and not lines[-1].strip(): lines.pop() tail = lines[-STATE_TAIL_WINDOW:] for line in tail: if RUNNING_RE.search(line): return {"state": "working", "match": None} for line in tail: if len(line) > MAX_OPTION_LEN: continue if OPEN_PROMPT_RE.match(line): return {"state": "open-prompt", "match": None} return {"state": "unknown", "match": None} def _cmdline(pid): """Argv of a pid as a list (empty when unreadable). Never raises.""" try: with open("/proc/%d/cmdline" % pid, "rb") as f: raw = f.read().split(b"\0") return [p.decode(errors="replace") for p in raw if p] except Exception: return [] def _child_pids(pid): """Direct children of pid via a bounded /proc scan. Never raises.""" out = [] try: names = os.listdir("/proc") except Exception: return out for name in names: if not name.isdigit(): continue try: with open("/proc/%s/stat" % name) as f: parts = f.read().rsplit(")", 1)[1].split() if int(parts[1]) == pid: out.append(int(name)) except Exception: continue return out def muse_approval_flags(cmd_argv): """Approval posture of a muse argv: {"auto_approve", "flags"}.""" flags = [] argv = cmd_argv or [] if "--yolo" in argv: flags.append("yolo") if "--disable-approval" in argv: flags.append("disable-approval") for i, arg in enumerate(argv): if arg == "--approval-mode" and i + 1 < len(argv): flags.append("approval-mode=%s" % argv[i + 1]) elif arg.startswith("--approval-mode="): flags.append("approval-mode=%s" % arg.split("=", 1)[1]) auto = ("yolo" in flags or "disable-approval" in flags or "approval-mode=never" in flags) return {"auto_approve": auto, "flags": flags} def launch_opt_out(cmd_argv): """True when a muse argv explicitly opts out of watcher auto-approve. Bare argv (no approval flags) means undefined -> the watcher answers. An explicit --approval-mode other than never (on-request, untrusted, ...) declares human-in-the-loop intent -> hold. """ argv = cmd_argv or [] for i, arg in enumerate(argv): mode = None if arg == "--approval-mode" and i + 1 < len(argv): mode = argv[i + 1] elif arg.startswith("--approval-mode="): mode = arg.split("=", 1)[1] if mode is not None and mode != "never": return True return False def pane_muse_argv(socket_path, pane_id): """Argv of the muse process in a pane (empty when not found).""" try: r = _tmux(socket_path, "list-panes", "-a", "-F", "#{pane_id} #{pane_pid}", timeout=5) if r.returncode != 0: return [] except Exception: return [] pane_pid = None for line in r.stdout.split("\n"): parts = line.split() if len(parts) == 2 and parts[0] == pane_id: try: pane_pid = int(parts[1]) except ValueError: return [] if pane_pid is None: return [] for cand in [pane_pid] + _child_pids(pane_pid): argv = _cmdline(cand) if argv and ("muse-bin" in argv[0] or "muse-code" in argv[0]): return argv return [] # Minimum pane geometry for reliable approval rendering. Empirically # derived: a 35x7 tile drops approval text the matcher needs, while # 35x35/36x35/71x27 panes answer cleanly. Below either bound the pane # is flagged squeezed (see `box runtime layout` / `spread`). MIN_APPROVAL_WIDTH = 40 MIN_APPROVAL_HEIGHT = 12 def runtime_rows(socket_path): """One row per pane: identity, approval posture, live state, watcher. Rows are JSON-serializable dicts for `box runtime list` and external agents. Panes that vanish mid-scan are skipped, never fatal. """ rows = [] try: r = _tmux(socket_path, "list-panes", "-a", "-F", "#{session_name}\t#{window_index}\t#{pane_id}\t" "#{pane_current_command}\t#{pane_pid}\t" "#{pane_width}\t#{pane_height}", timeout=10) if r.returncode != 0: return rows except Exception: return rows for line in r.stdout.split("\n"): parts = line.split("\t") if len(parts) != 7: continue session, window, pane_id, cmd, pid, width, height = parts try: pane_pid = int(pid) except ValueError: pane_pid = None try: pane_width, pane_height = int(width), int(height) except ValueError: pane_width = pane_height = None squeezed = (pane_width is not None and pane_height is not None and (pane_width < MIN_APPROVAL_WIDTH or pane_height < MIN_APPROVAL_HEIGHT)) is_muse = "muse-bin" in cmd or "muse-code" in cmd posture = {"auto_approve": None, "flags": []} if is_muse and pane_pid: candidates = [pane_pid] + _child_pids(pane_pid) for cand in candidates: argv = _cmdline(cand) if argv and ("muse-bin" in argv[0] or "muse-code" in argv[0]): posture = muse_approval_flags(argv) break text = capture_pane(socket_path, pane_id) if text is None: continue st = runtime_state(text) match = st["match"] or {} watcher_pid = is_running(socket_path, pane_id) rows.append({ "socket": socket_path, "session": session, "window": window, "pane": pane_id, "cmd": cmd, "pid": pane_pid, "is_muse": is_muse, "width": pane_width, "height": pane_height, "squeezed": squeezed, "auto_approve": posture["auto_approve"], "approval_flags": posture["flags"], "state": st["state"], "prompt_kind": match.get("kind"), "prompt_key": match.get("key"), "watcher_alive": watcher_pid is not None, "watcher_pid": watcher_pid, }) return rows def spread_targets(rows): """Rows that `box runtime spread` would break into own windows. A row is a target when it runs a coding runtime (muse today; other harnesses follow the registry) and its geometry is squeezed below the reliable-approval minimums. Pure function of rows for testing. """ return [r for r in rows if r.get("is_muse") and r.get("squeezed")] def all_runtime_rows(sockets=None): """runtime_rows across every known socket that exists.""" rows = [] for sock in sockets or KNOWN_SOCKETS: if not os.path.exists(sock): continue rows.extend(runtime_rows(sock)) return rows def pane_state(socket_path, pane_id): """Single-pane runtime row, or {"error": ...} when not found.""" for row in runtime_rows(socket_path): if row["pane"] == pane_id: return row return {"error": "no_such_pane", "socket": socket_path, "pane": pane_id} class WatcherState: """Tracks prompt stability and once-per-prompt answering.""" def __init__(self): self.pending_sig = None self.stable_count = 0 self.answered_sigs = {} self.answer_times = [] self.last_capped_sig = None def _prune_times(self, now): cutoff = now - 3600 self.answer_times = [t for t in self.answer_times if t >= cutoff] def cap_reached(self, now): self._prune_times(now) return len(self.answer_times) >= MAX_ANSWERS_PER_HOUR def observe(self, match, now): """Feed one poll's match (or None). Returns "answer" when the prompt is stable, unanswered, and under the hourly cap.""" if match is None: self.pending_sig = None self.stable_count = 0 return "none" sig = match["sig"] if sig in self.answered_sigs: if now - self.answered_sigs[sig] < ANSWERED_TTL_SECONDS: return "none" # Expired: an identical prompt still (or again) present long # after its answer is a stuck dialog, not a duplicate -- allow # one recovery answer instead of wedging forever. del self.answered_sigs[sig] if sig == self.pending_sig: self.stable_count += 1 else: self.pending_sig = sig self.stable_count = 1 if self.stable_count < STABILITY_POLLS: return "wait" if self.cap_reached(now): return "capped" return "answer" def record_answer(self, sig, now): self.answered_sigs[sig] = now # Bound memory: drop expired entries, then oldest-first past the cap. cutoff = now - ANSWERED_TTL_SECONDS for s in [s for s, t in self.answered_sigs.items() if t < cutoff]: del self.answered_sigs[s] if len(self.answered_sigs) > 200: for s in sorted(self.answered_sigs, key=lambda s: self.answered_sigs[s])[:-100]: del self.answered_sigs[s] self.answer_times.append(now) self.pending_sig = None self.stable_count = 0 def _tmux(socket_path, *args, timeout=5): cmd = ["tmux", "-S", socket_path] + list(args) return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) def pane_exists(socket_path, pane_id): try: r = _tmux(socket_path, "list-panes", "-a", "-F", "#{pane_id}", timeout=5) if r.returncode != 0: return False return pane_id in r.stdout.split() except Exception: return False def capture_pane(socket_path, pane_id, history=80): try: r = _tmux(socket_path, "capture-pane", "-p", "-t", pane_id, "-S", "-%d" % history, timeout=5) if r.returncode != 0: return None return r.stdout except Exception: return None def send_answer(socket_path, pane_id, letter="A", enter=True): """Type the choice key (+ Enter unless enter=False). True on success.""" try: r1 = _tmux(socket_path, "send-keys", "-t", pane_id, letter, timeout=5) if r1.returncode != 0: return False if not enter: return True r2 = _tmux(socket_path, "send-keys", "-t", pane_id, "Enter", timeout=5) return r2.returncode == 0 except Exception: return False def _poll_once(socket_path, pane_id, state, log, dry_run=False): """Run one poll iteration. Returns an outcome string ("gone" tells the caller to exit). Logs only state transitions, never per-poll spam.""" if not pane_exists(socket_path, pane_id): return "gone" text = capture_pane(socket_path, pane_id) if text is None: log.log("warn", "capture failed, continuing") return "capture-failed" match = find_choice_prompt(text) now = time.time() verdict = state.observe(match, now) if verdict == "wait" and state.stable_count == 1: log.log("info", "prompt seen", kind=match["kind"], key=match["key"], sig=match["sig"], text=(match["cue"] or "")[:200]) return "seen" if verdict == "answer": log.log("info", "prompt stable, answering", kind=match["kind"], key=match["key"], sig=match["sig"]) # Re-verify the prompt is still present before typing. confirm = capture_pane(socket_path, pane_id) cmatch = find_choice_prompt(confirm) if confirm else None if cmatch is None or cmatch["sig"] != match["sig"]: log.log("info", "prompt vanished before answer, skipping", sig=match["sig"]) state.pending_sig = None state.stable_count = 0 return "vanished" if launch_opt_out(pane_muse_argv(socket_path, pane_id)): log.log("info", "held: pane opted out via launch flags", sig=match["sig"], kind=match["kind"]) state.record_answer(match["sig"], now) return "held" key = match["key"] if dry_run: log.log("info", "dry-run would answer %s" % key, sig=match["sig"], kind=match["kind"], key=key, options=match["options"], cue=match["cue"]) state.record_answer(match["sig"], now) return "dry-answered" ok = send_answer(socket_path, pane_id, key, enter=match.get("enter", True)) state.record_answer(match["sig"], now) log.log("info" if ok else "error", "answered %s" % key, sig=match["sig"], ok=ok, kind=match["kind"], key=key, options=match["options"], cue=match["cue"]) audit("muse-choice-answered", name="%s:%s" % (os.path.basename(socket_path), pane_id), extra={"socket": socket_path, "pane": pane_id, "sig": match["sig"], "ok": ok, "kind": match["kind"], "key": key, "options": match["options"], "cue": match["cue"]}) return "answered" if verdict == "capped": if state.last_capped_sig != match["sig"]: state.last_capped_sig = match["sig"] log.log("warn", "hourly answer cap reached, holding", sig=match["sig"], kind=match["kind"]) return "capped" if verdict == "none": state.last_capped_sig = None return "none" return "waiting" def watch_loop(socket_path, pane_id, dry_run=False): """Main daemon loop. Returns only when the pane is gone or signalled.""" log = WatcherLog(logfile_for(socket_path, pane_id)) state = WatcherState() log.log("info", "watcher started", socket=socket_path, pane=pane_id, dry_run=dry_run, pid=os.getpid()) polls = 0 answers = 0 last_heartbeat = time.time() while True: try: outcome = _poll_once(socket_path, pane_id, state, log, dry_run=dry_run) if outcome == "gone": log.log("info", "pane gone, exiting", polls=polls, answers=answers) return 0 if outcome in ("answered", "dry-answered"): answers += 1 polls += 1 if time.time() - last_heartbeat >= HEARTBEAT_SECONDS: last_heartbeat = time.time() log.log("info", "heartbeat", polls=polls, answers=answers, pending=state.pending_sig) except Exception as e: try: log.log("error", "poll iteration failed, continuing", error="%r" % (e,)) except Exception: pass time.sleep(POLL_INTERVAL) def _pid_alive(pid): try: os.kill(pid, 0) return True except Exception: return False def _pid_is_watcher(pid): """True if pid looks like our watcher (guards against pid reuse).""" try: with open("/proc/%d/cmdline" % pid, "rb") as f: cmd = f.read().decode(errors="replace") return "muse_choice_watcher" in cmd except Exception: return False def _watch_procs(proc_root="/proc"): """Live processes running this module's foreground `watch` argv. Returns [{"pid", "socket", "pane"}]. Exact argv match only -- it can never misidentify another program, so callers may act on the result. """ out = [] try: pids = os.listdir(proc_root) except OSError: return out me = os.getpid() for pid in pids: if not pid.isdigit() or int(pid) == me: continue try: with open(os.path.join(proc_root, pid, "cmdline"), "rb") as f: parts = f.read().split(b"\0") except (OSError, IOError): continue try: args = [p.decode(errors="replace") for p in parts if p] except Exception: continue if len(args) < 6: continue if not args[1].endswith("muse_choice_watcher.py") or args[2] != "watch": continue try: sock = args[args.index("--socket") + 1] pane = args[args.index("--pane") + 1] except (ValueError, IndexError): continue out.append({"pid": int(pid), "socket": sock, "pane": pane}) return out def is_running(socket_path, pane_id): pidfile = pidfile_for(socket_path, pane_id) try: with open(pidfile) as f: pid = int(f.read().strip()) except Exception: return None if _pid_alive(pid) and _pid_is_watcher(pid): return pid try: os.remove(pidfile) except OSError: pass return None def _daemonize(): """Double-fork away from the controlling terminal (survives shell exit).""" if os.fork() != 0: os._exit(0) os.setsid() if os.fork() != 0: os._exit(0) devnull = os.open(os.devnull, os.O_RDWR) os.dup2(devnull, 0) os.dup2(devnull, 1) os.dup2(devnull, 2) if devnull > 2: os.close(devnull) def _claim_pidfile(pidfile): """Claim a pidfile for this process. Returns False if another live watcher owns it (concurrent box + timer starts must not pile up).""" me = os.getpid() try: with open(pidfile) as f: other = int(f.read().strip()) except Exception: other = None if other and other != me and _pid_alive(other) and _pid_is_watcher(other): return False try: with open(pidfile, "w") as f: f.write(str(me)) except Exception: pass return True def start_watcher(socket_path, pane_id, dry_run=False): pid = is_running(socket_path, pane_id) if pid: return {"ok": False, "status": "already_running", "pid": pid} if not pane_exists(socket_path, pane_id): return {"ok": False, "status": "no_such_pane"} _daemonize() # Child continues here with a clean identity: re-exec ourselves as a # foreground `watch` process so /proc cmdline always names this module # (liveness checks and ps output stay correct no matter who spawned us -- # box, timer, or direct CLI). args = [sys.executable, os.path.abspath(__file__), "watch", "--socket", socket_path, "--pane", pane_id] if dry_run: args.append("--dry-run") os.execvp(sys.executable, args) os._exit(1) # unreachable; exec replaces the image def stop_watcher(socket_path, pane_id, timeout=5): pid = is_running(socket_path, pane_id) if not pid: return {"ok": True, "status": "not_running"} try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass deadline = time.time() + timeout while time.time() < deadline and _pid_alive(pid): time.sleep(0.1) if _pid_alive(pid): try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass try: os.remove(pidfile_for(socket_path, pane_id)) except OSError: pass return {"ok": True, "status": "stopped", "pid": pid} def muse_panes(socket_path): """Pane ids on a socket whose current command looks like Muse.""" try: r = _tmux(socket_path, "list-panes", "-a", "-F", "#{pane_id} #{pane_current_command}", timeout=10) if r.returncode != 0: return [] except Exception: return [] out = [] for line in r.stdout.split("\n"): parts = line.strip().split(None, 1) if len(parts) == 2 and "muse-bin" in parts[1]: out.append(parts[0]) return out def _start_detached(socket_path, pane_id, dry_run=False): """Fork off a watcher via start_watcher (which daemonizes further). Returns True if a watcher is running for the pane afterwards.""" pid = os.fork() if pid == 0: start_watcher(socket_path, pane_id, dry_run=dry_run) os._exit(0) os.waitpid(pid, 0) time.sleep(0.2) return is_running(socket_path, pane_id) is not None def start_all(dry_run=False, sockets=None): results = [] for sock in sockets or KNOWN_SOCKETS: if not os.path.exists(sock): results.append({"socket": sock, "status": "no_socket"}) continue panes = muse_panes(sock) if not panes: results.append({"socket": sock, "status": "no_muse_panes"}) for pane in panes: if is_running(sock, pane): results.append({"socket": sock, "pane": pane, "status": "already_running"}) continue ok = _start_detached(sock, pane, dry_run=dry_run) results.append({"socket": sock, "pane": pane, "status": "started" if ok else "failed"}) return results def stop_all(): results = [] stopped_pids = set() prefix = FILE_PREFIX + "-" try: names = os.listdir(STATE_DIR) except OSError: names = [] for name in names: if not name.startswith(prefix) or not name.endswith(".pid"): continue try: with open(os.path.join(STATE_DIR, name)) as f: pid = int(f.read().strip()) except Exception: continue if _pid_alive(pid) and _pid_is_watcher(pid): try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass stopped_pids.add(pid) results.append({"pidfile": name, "pid": pid, "status": "stopped"}) try: os.remove(os.path.join(STATE_DIR, name)) except OSError: pass # "off means off": also stop exact-argv watchers running pidfile-less. for proc in _watch_procs(): if proc["pid"] in stopped_pids: continue try: os.kill(proc["pid"], signal.SIGTERM) except ProcessLookupError: pass results.append({"pidfile": None, "pid": proc["pid"], "status": "stopped-orphan", "socket": proc["socket"], "pane": proc["pane"]}) return results def reconcile(sockets=None): """Enforce desired state: start missing watchers when enabled, stop all when disabled, prune dead pidfiles. Audits only when it changed something. Returns a summary dict.""" desired = get_desired() started, already, pruned, failed = [], [], [], [] stopped = [] if desired["enabled"]: for sock in sockets or KNOWN_SOCKETS: if not os.path.exists(sock): continue for pane in muse_panes(sock): if is_running(sock, pane): already.append("%s:%s" % (sock, pane)) continue if _start_detached(sock, pane, dry_run=desired["dry_run"]): started.append("%s:%s" % (sock, pane)) else: failed.append("%s:%s" % (sock, pane)) for row in status_all(): if not row["alive"]: try: os.remove(os.path.join(STATE_DIR, row["pidfile"])) pruned.append(row["pidfile"]) except OSError: pass else: stopped = ["%s:%s" % (r.get("pidfile"), r.get("pid")) for r in stop_all()] if started or stopped or pruned or failed: audit("muse-choice-reconciled", extra={"started": started, "stopped": stopped, "pruned": pruned, "failed": failed}) return {"enabled": desired["enabled"], "dry_run": desired["dry_run"], "started": started, "already": already, "stopped": stopped, "pruned": pruned, "failed": failed} def recent_answers(limit=10, scan_lines=3000): """Most recent muse-choice-answered audit records (newest last).""" try: with open(CTL_LOG) as f: lines = f.readlines()[-scan_lines:] except OSError: return [] out = [] for line in lines: try: rec = json.loads(line) except Exception: continue if rec.get("action") == "muse-choice-answered": out.append(rec) return out[-limit:] def status_all(): rows = [] prefix = FILE_PREFIX + "-" try: names = sorted(os.listdir(STATE_DIR)) except OSError: return rows for name in names: if not name.startswith(prefix) or not name.endswith(".pid"): continue pid = None try: with open(os.path.join(STATE_DIR, name)) as f: pid = int(f.read().strip()) except Exception: pass alive = bool(pid) and _pid_alive(pid) and _pid_is_watcher(pid) logname = name[:-4] + ".log" logsize = None try: logsize = os.path.getsize(os.path.join(STATE_DIR, logname)) except OSError: pass rows.append({"pidfile": name, "pid": pid, "alive": alive, "log": logname, "log_bytes": logsize}) owned = set(r["pid"] for r in rows if r["alive"] and r["pid"]) for proc in _watch_procs(): if proc["pid"] in owned: continue logname = os.path.basename( logfile_for(proc["socket"], proc["pane"])) try: logsize = os.path.getsize(os.path.join(STATE_DIR, logname)) except OSError: logsize = None rows.append({"pidfile": None, "pid": proc["pid"], "alive": True, "orphan": True, "socket": proc["socket"], "pane": proc["pane"], "log": logname, "log_bytes": logsize}) return rows def main(argv=None): ap = argparse.ArgumentParser(description="Muse A/B/C choice watcher") sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("start", help="start watching one pane (daemonizes)") p.add_argument("--socket", required=True) p.add_argument("--pane", required=True) p.add_argument("--dry-run", action="store_true", help="log answers instead of sending keys") p = sub.add_parser("stop", help="stop one pane watcher") p.add_argument("--socket", required=True) p.add_argument("--pane", required=True) p = sub.add_parser("start-all", help="watch every Muse pane on known sockets") p.add_argument("--dry-run", action="store_true") p.add_argument("--socket", action="append", dest="sockets", default=None) sub.add_parser("stop-all", help="stop all watchers") sub.add_parser("status", help="list watcher state files + liveness") p = sub.add_parser("on", help="enable auto-answers (desired state) + reconcile now") p.add_argument("--dry-run", action="store_true", help="log answers instead of sending keys") p.add_argument("--by", default="cli", help="caller identity for audit") p = sub.add_parser("off", help="disable auto-answers (desired state) + stop all now") p.add_argument("--by", default="cli", help="caller identity for audit") p = sub.add_parser("reconcile", help="enforce desired state (for timer)") p.add_argument("--socket", action="append", dest="sockets", default=None) p = sub.add_parser("watch", help="run one pane loop in foreground (daemon target)") p.add_argument("--socket", required=True) p.add_argument("--pane", required=True) p.add_argument("--dry-run", action="store_true") p = sub.add_parser("match", help="print JSON verdict for text on stdin") p.add_argument("--tail-window", type=int, default=TAIL_WINDOW) p = sub.add_parser("logs", help="tail one pane watcher log") p.add_argument("--socket", required=True) p.add_argument("--pane", required=True) p.add_argument("-n", type=int, default=20) p = sub.add_parser("state", help="print JSON runtime state for one pane") p.add_argument("--socket", required=True) p.add_argument("--pane", required=True) args = ap.parse_args(argv) if args.cmd == "start": existing = is_running(args.socket, args.pane) if existing: print(json.dumps({"ok": True, "status": "already_running", "pid": existing, "log": logfile_for(args.socket, args.pane)})) return 0 # start_watcher never returns in the daemon child; the parent fork # dance happens inside, so wrap: fork here, child calls start. pid = os.fork() if pid == 0: start_watcher(args.socket, args.pane, dry_run=args.dry_run) os._exit(0) _, status = os.waitpid(pid, 0) time.sleep(0.3) running = is_running(args.socket, args.pane) print(json.dumps({"ok": running is not None, "pid": running, "log": logfile_for(args.socket, args.pane)})) return 0 if running else 1 if args.cmd == "stop": print(json.dumps(stop_watcher(args.socket, args.pane))) return 0 if args.cmd == "start-all": print(json.dumps(start_all(dry_run=args.dry_run, sockets=args.sockets), indent=1)) return 0 if args.cmd == "stop-all": print(json.dumps(stop_all(), indent=1)) return 0 if args.cmd == "status": print(json.dumps({"desired": get_desired(), "watchers": status_all()}, indent=1)) return 0 if args.cmd == "on": state = set_enabled(True, dry_run=args.dry_run, by=args.by) res = reconcile() print(json.dumps({"desired": state, "reconcile": res}, indent=1)) return 0 if args.cmd == "off": state = set_enabled(False, by=args.by) stopped = stop_all() print(json.dumps({"desired": state, "stopped": stopped}, indent=1)) return 0 if args.cmd == "reconcile": print(json.dumps(reconcile(sockets=args.sockets), indent=1)) return 0 if args.cmd == "watch": # Refuse to pile on: an identical watcher may be alive with a lost # pidfile (e.g. /tmp cleaned under it). Exact argv match, no guessing. for proc in _watch_procs(): if proc["socket"] == args.socket and proc["pane"] == args.pane: return 3 pidfile = pidfile_for(args.socket, args.pane) if not _claim_pidfile(pidfile): return 3 try: return watch_loop(args.socket, args.pane, dry_run=args.dry_run) finally: try: with open(pidfile) as f: owner = f.read().strip() except Exception: owner = "" if owner == str(os.getpid()): try: os.remove(pidfile) except OSError: pass if args.cmd == "match": text = sys.stdin.read() print(json.dumps(find_choice_prompt(text, tail_window=args.tail_window), indent=1)) return 0 if args.cmd == "logs": path = logfile_for(args.socket, args.pane) try: with open(path) as f: lines = f.readlines() except OSError as e: print("no log: %s (%s)" % (path, e)) return 1 for line in lines[-args.n:]: print(line.rstrip("\n")) return 0 if args.cmd == "state": print(json.dumps(pane_state(args.socket, args.pane), indent=1)) return 0 return 1 if __name__ == "__main__": sys.exit(main())