#!/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", cursorless dotted 1./2./3. grill question -> "1" (rule-held for coordinator sign-off, never expires to approve), y/n line-end prompt -> "y", /permissions screens -> builtin-hold (never auto-answered; the operator drives mode changes by hand). 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 fcntl import hashlib import json import os import re import shlex import shutil 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", ] # Sockets whose sole purpose is self-running fleet agents. Prompts on # fleet sockets are answered (and logged) regardless of approval-mode # launch flags, and get a higher hourly answer budget: a headless # builder under on-request approvals burns many answers per hour. FLEET_SOCKETS = [ "/tmp/tmux-muse.sock", ] POLL_INTERVAL = 0.5 STABILITY_POLLS = 2 MAX_ANSWERS_PER_HOUR = 20 FLEET_MAX_ANSWERS_PER_HOUR = 200 ANSWERED_TTL_SECONDS = 600 # identical prompt back after 10m => stuck, allow one recovery answer CLAIM_TTL_SECONDS = 30 # concurrent-claim window: bounds wedge if winner dies pre-send RULES_FILE = os.path.join(REPO_ROOT, "muse-choices-rules.json") HOLD_WINDOW_SECONDS = 120 # D3: short hold window, then expire to approve NEGATIVE_KEYS = {"muse-approval": "2", "yn": "n"} # D2 deny keys QUESTION_KINDS = frozenset({"interview", "letter", "numbered", "explicit-phrase", "numbered-plain", "permissions"}) HOLD_FOREVER_KINDS = frozenset({"numbered-plain"}) # coordinator-gated: # holds renew instead of expiring to approve; resolve or manual answer only AGENT_PANE_HINTS = ("muse-bin", "muse-code", "agy.bin") # pane_current_command substrings: every agent harness is watched by # default. muse-code is the launch shim: a fresh pane shows it until # the muse-bin exec lands, so omitting it loses the launch race (no # watcher starts and none is reported). HARNESS_HINTS = (("muse", ("muse-bin", "muse-code")), ("agy", ("agy.bin",))) # argv[0] substrings per harness AGY_HOLD_RULE = "agy-hold-all" # builtin: agy sends unproven, hold all _RULES_CACHE = {"key": None, "rules": []} LOG_MAX_BYTES = 1_000_000 HEARTBEAT_SECONDS = 60 SUBMIT_GAP_SECONDS = 0.4 # text..Enter gap: the muse composer treats a # back-to-back burst as paste and inserts a newline instead of submitting COMPOSER_KINDS = frozenset({"letter", "numbered", "yn", "explicit-phrase", "numbered-plain"}) VERIFY_POLL_SECONDS = 0.1 VERIFY_TEXT_TIMEOUT = 1.5 # unchanged past this: fail open to blind send POST_ENTER_SETTLE = 0.4 # submit processing before the post-verify capture COMPOSER_TAIL_WINDOW = 10 # trailing lines scanned for the live input row 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) WRAPPED_TAIL_CAP = 4 # wrapped continuation rows skipped past the last option CUE_BELOW_ROWS = 3 # cue may sit this far below the option block end # 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 # Cursorless dotted 1./2./3. grill question (observed live on a policy # grill): ordered dotted options, a ?-ended question above, and a strong # pick-cue nearby. Both the question and the cue are required: bare # prose lists must never match. Any cursor-prefixed dotted line vetoes # (cursor menus belong to the interview matcher). NUMBERED_PLAIN_OPT_RE = re.compile(r"^\s*(\d+)\.\s+\S") NUMBERED_PLAIN_CURSOR_RE = re.compile("^\\s*[>\\u203a]\\s*\\d+\\.\\s+\\S") NUMBERED_PLAIN_CUE_RE = re.compile( r"(?i)\bpick\s+(one|[123]|a number)\b|\breply\s+[123]\b" r"|\bchoose\s+(one|[123])\b|\benter\s+[123]\b" r"|\bselect\s+an?\s*(option|one|number)\b|\byour choice\b") # 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 # /permissions mode picker (pasted verbatim from a live session): # Permissions # Selections are saved as your default for new sessions and exec runs. # 1. Read only Reads files only. # 2. Ask me Edits workspace ... # 3. Auto-review Same access as Ask me; ... # › 4. Unrestricted (current) No filesystem sandbox ... # A mode change persists as the default for new sessions and exec runs, # so the picker is builtin-hold (operator drives the TUI by hand) and # is never auto-answered. The (current) marker names the live mode. PERMISSIONS_HEADER_RE = re.compile(r"^\s*Permissions\s*$") PERMISSIONS_OPT_RE = re.compile( "^\\s*([>\\u203a])?\\s*([1-4])\\.\\s+" "(Read only|Ask me|Auto-review|Unrestricted)\\b") PERMISSIONS_CURRENT_RE = re.compile(r"\(current\)") PERMISSIONS_SPAN = 18 # header..option 4 (wrapped descriptions ok) # /permissions enable confirmation (pasted verbatim from live): # Enable unrestricted permissions? # Profile: Unrestricted # ... # Cancel # › Enable Unrestricted # Confirming flips the session (and the saved default) out of the # logged-approval path, so it is builtin-hold like the picker. PERMISSIONS_CONFIRM_TITLE_RE = re.compile( r"^\s*Enable\s+(.+?)\s+permissions\?\s*$", re.IGNORECASE) PERMISSIONS_CONFIRM_OPT_RE = re.compile( "^\\s*([>\\u203a])?\\s*(Cancel|Enable\\s+\\S.*?)\\s*$") PERMISSIONS_CONFIRM_SPAN = 14 # title..options span # 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)) ) def answered_file_for(socket_path, pane_id): """On-disk answered-sig store: restarts must not re-answer prompts.""" return os.path.join( STATE_DIR, "%s-%s-%s.answered.json" % (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 _option_block_end(window, end, marker_re): """Last row of an option block: skip its wrapped continuation rows. Captures are width-chunked, not joined, so an option's text may wrap onto several display rows past its marker line. Cue distance is measured past the block, not the marker. Stops at blank rows, at the next marker, and at WRAPPED_TAIL_CAP rows past the marker (bounding the net: a cue far below a long tail still rejects). """ tail = end while (tail + 1 < len(window) and tail + 1 - end <= WRAPPED_TAIL_CAP and window[tail + 1].strip() and not marker_re.match(window[tail + 1])): tail += 1 return tail 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) tail = _option_block_end(window, end, OPTION_RE) hi = min(len(window), tail + 1 + CUE_BELOW_ROWS) 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) tail = _option_block_end(window, end, NUMBERED_OPT_RE) hi = min(len(window), tail + 1 + CUE_BELOW_ROWS) 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, "context": context, "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, "context": context, "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 _ordered_int_run(markers): """Complete 1,2[,3...] runs; returns the NEWEST (last) one or None. Unlike _ordered_run (first on ties), grill windows stack an answered block above the live one: the live prompt is the lower run. """ runs = [] for start in range(len(markers)): if markers[start][1] != 1: continue run = [markers[start]] for idx in range(start + 1, len(markers)): if markers[idx][1] == len(run) + 1: run.append(markers[idx]) else: break if len(run) >= 2 and run[-1][0] - run[0][0] <= OPTION_SPAN: runs.append(run) return runs[-1] if runs else None def _find_numbered_plain(window): """Cursorless dotted 1./2./3. grill question -> match or None. Ordered dotted options starting at 1, a ?-ended question above, and a strong pick-cue nearby. Both the question and the cue are required: bare prose lists must never match. Any cursor-prefixed dotted line vetoes (cursor menus belong to _find_interview). """ for line in window: if NUMBERED_PLAIN_CURSOR_RE.match(line): return None markers = [] for i, line in enumerate(window): if len(line) > MAX_OPTION_LEN: continue m = NUMBERED_PLAIN_OPT_RE.match(line) if m: markers.append((i, int(m.group(1)))) run = _ordered_int_run(markers) if not run: return None start, end = run[0][0], run[-1][0] question = None for qi in range(max(0, start - INTERVIEW_CUE_ABOVE), start): if QUESTION_RE.search(window[qi]): question = window[qi].strip() if question is None: return None lo = max(0, start - 4) tail = _option_block_end(window, end, NUMBERED_PLAIN_OPT_RE) hi = min(len(window), tail + 1 + CUE_BELOW_ROWS) cue = None for i in range(lo, hi): if NUMBERED_PLAIN_CUE_RE.search(window[i]): cue = window[i].strip() break if cue is None: return None options = [window[i].strip()[:120] for i, _ in run] question = question[:200] return {"sig": _sig_for("numbered-plain", options + [question]), "kind": "numbered-plain", "key": "1", "options": options, "cue": question, "start": start, "end": end} 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_permissions_picker(window): """/permissions mode picker -> match or None. Requires the exact "Permissions" header, all four mode options in 1..4 order, a (current) marker naming the live mode, and a cursor on an option line (proves a live selectable menu). The sig covers labels + current mode but not cursor position, so operator navigation doesn't churn holds while an actual mode change does. Key is None: the picker is builtin-hold, never auto-answered. """ header = None for i, line in enumerate(window): if len(line) > MAX_OPTION_LEN: continue if PERMISSIONS_HEADER_RE.match(line): header = i break if header is None: return None found = {} cursor = False end = header for i in range(header + 1, min(len(window), header + 1 + PERMISSIONS_SPAN)): line = window[i] if len(line) > MAX_OPTION_LEN: continue m = PERMISSIONS_OPT_RE.match(line) if not m: continue if m.group(1): cursor = True found[int(m.group(2))] = (i, m.group(3), bool(PERMISSIONS_CURRENT_RE.search(line))) end = i if sorted(found) != [1, 2, 3, 4] or not cursor: return None current = next((label for _, label, is_cur in found.values() if is_cur), None) if current is None: return None options = ["%d. %s" % (n, found[n][1]) for n in (1, 2, 3, 4)] cue = "Permissions (current: %s)" % current return {"sig": _sig_for("permissions", options + [current]), "kind": "permissions", "key": None, "options": options, "cue": cue, "current": current, "start": header, "end": end} def _find_permissions_confirm(window): """/permissions enable confirmation -> match or None. Requires an "Enable permissions?" title plus Cancel and Enable-... option lines with a cursor on one. Sig excludes cursor position (navigation churn, see picker). Key None: builtin-hold. """ title = None profile = None for i, line in enumerate(window): if len(line) > MAX_OPTION_LEN: continue m = PERMISSIONS_CONFIRM_TITLE_RE.match(line) if m: title = i profile = m.group(1).strip() break if title is None: return None seen_cancel = seen_enable = cursor = False end = title for i in range(title + 1, min(len(window), title + 1 + PERMISSIONS_CONFIRM_SPAN)): line = window[i] if len(line) > MAX_OPTION_LEN: continue m = PERMISSIONS_CONFIRM_OPT_RE.match(line) if not m: continue if m.group(1): cursor = True opt = m.group(2) if opt == "Cancel": seen_cancel = True elif opt.startswith("Enable"): seen_enable = True end = i if not (seen_cancel and seen_enable and cursor): return None cue = window[title].strip()[:200] options = ["Cancel", "Enable %s" % profile] if profile else ["Cancel"] return {"sig": _sig_for("permissions", [cue]), "kind": "permissions", "key": None, "options": options, "cue": cue, "current": None, "start": title, "end": end} def find_choice_prompt(text, tail_window=TAIL_WINDOW): """Detect a Muse prompt awaiting reply. Kinds (priority order): permissions (/permissions picker or enable confirmation -> builtin-hold, never auto-answered), 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"), numbered-plain (cursorless dotted 1./2./3. grill question + pick-cue -> key "1", coordinator-held), 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_permissions_picker(window) or _find_permissions_confirm(window) or _find_muse_approval(window) or _find_collapsed_approval(window) or _find_interview(window) or _find_numbered_plain(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. Returns {"auto_approve", "flags", "profile", "mode", "bypass"}. profile is the --permission-profile id (built-ins :read-only, :standard, :unrestricted) or None; mode is "yolo" for --yolo, the profile id when one was passed, else "default"; bypass is True when tool-approval prompts are disabled at launch (yolo, --disable-approval, or --approval-mode=never), in which case the pane shows no approval dialogs (questions may still render, so the watcher stays active). """ flags = [] argv = cmd_argv or [] if "--yolo" in argv: flags.append("yolo") if "--disable-approval" in argv: flags.append("disable-approval") if "--disable-sandbox" in argv: flags.append("disable-sandbox") if "--trust-workspace" in argv: flags.append("trust-workspace") profile = None 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]) elif arg == "--permission-profile" and i + 1 < len(argv): profile = argv[i + 1] flags.append("permission-profile=%s" % profile) elif arg.startswith("--permission-profile="): profile = arg.split("=", 1)[1] flags.append("permission-profile=%s" % profile) auto = ("yolo" in flags or "disable-approval" in flags or "approval-mode=never" in flags) if "yolo" in flags: mode = "yolo" elif profile is not None: mode = profile else: mode = "default" return {"auto_approve": auto, "flags": flags, "profile": profile, "mode": mode, "bypass": auto} 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_holds_for_optout(socket_path, cmd_argv): """True when the watcher must hold prompts for launch-flag opt-out. Fleet sockets host self-running agents: prompts there are answered (and logged) regardless of approval-mode flags, since nobody is watching to answer by hand. Elsewhere an explicit --approval-mode other than never declares human-in-the-loop intent -> hold. """ if socket_path in FLEET_SOCKETS: return False return launch_opt_out(cmd_argv) def hourly_cap_for(socket_path): """Hourly answer budget for a pane loop on socket_path.""" if socket_path in FLEET_SOCKETS: return FLEET_MAX_ANSWERS_PER_HOUR return MAX_ANSWERS_PER_HOUR def approval_determined(cmd_argv): """True when argv already determines approval behavior. yolo / disable-approval / approval-mode / permission-profile all settle it; sandbox and workspace-trust flags do not (they ride along with whatever approval posture applies). """ posture = muse_approval_flags(cmd_argv) if posture["profile"] is not None: return True flags = posture["flags"] if "yolo" in flags or "disable-approval" in flags: return True return any(f.startswith("approval-mode=") for f in flags) def muse_launch_cmdline(muse_args): """Build a fleet launch command line. Strips a leading "--" separator; bare launches (no approval-determining flags) get on-request approvals injected so prompts render for the watcher trail. Returns (cmdline, injected). Shared by `box runtime launch` and the runtime reconciler so both spawn identical sessions. """ args = list(muse_args or []) if args[:1] == ["--"]: args = args[1:] injected = ([] if approval_determined(args) else ["--approval-mode", "on-request"]) launcher = (shutil.which("muse-code") or "/home/super/.local/bin/muse-code") return (shlex.join([launcher] + injected + args), injected) def saved_default_profile(settings_path=None): """Saved default permission profile id, or None. Reads permissions.default_profile from the muse settings file (e.g. ":unrestricted" after driving the /permissions picker). The app applies this to sessions launched without explicit approval flags; launch flags override it ("Launch overrides" footer). Never raises. """ path = settings_path or os.path.expanduser( "~/.config/muse/settings.json") try: with open(path) as f: data = json.load(f) except Exception: return None try: return data.get("permissions", {}).get("default_profile") except Exception: return None def resolve_socket(sock): """Resolve a user-supplied tmux socket alias to a server path. Tables show basenames (tmux-muse.sock), so callers copy them back as --socket values. As-given wins when it exists; otherwise match by basename against KNOWN_SOCKETS, then /tmp/. Unknown values pass through unchanged so downstream errors still name what was given. Pure apart from existence probes. """ if not sock or os.path.exists(sock): return sock base = os.path.basename(sock) for known in KNOWN_SOCKETS: if os.path.basename(known) == base: return known cand = os.path.join("/tmp", base) if os.path.exists(cand): return cand return sock def effective_posture(posture, saved_default=None): """Effective approval posture: explicit flags win, else saved default. Returns {"effective_mode", "effective_bypass"}. effective_bypass is True when the pane shows no approval dialogs (explicit yolo / disable-approval / never, an unrestricted profile, or an unrestricted saved default with no explicit flags). Modes are display-ready (leading ":" stripped from profile ids). Pure. """ flags = posture.get("flags") or [] profile = posture.get("profile") approval_mode = None for f in flags: if f.startswith("approval-mode="): approval_mode = f.split("=", 1)[1] if "yolo" in flags: return {"effective_mode": "yolo", "effective_bypass": True} if "disable-approval" in flags or approval_mode == "never": if profile is not None: mode = profile.lstrip(":") elif approval_mode is not None: mode = approval_mode else: mode = "default" return {"effective_mode": mode, "effective_bypass": True} if approval_mode is not None: return {"effective_mode": approval_mode, "effective_bypass": False} if profile is not None: return {"effective_mode": profile.lstrip(":"), "effective_bypass": profile == ":unrestricted"} if saved_default: return {"effective_mode": str(saved_default).lstrip(":"), "effective_bypass": saved_default == ":unrestricted"} return {"effective_mode": "default", "effective_bypass": False} def _pane_tree_argvs(socket_path, pane_id): """Argvs of a pane's root pid plus its direct children (may be empty).""" 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 [] return [argv for argv in (_cmdline(c) for c in [pane_pid] + _child_pids(pane_pid)) if argv] def pane_muse_argv(socket_path, pane_id): """Argv of the muse process in a pane (empty when not found).""" for argv in _pane_tree_argvs(socket_path, pane_id): if "muse-bin" in argv[0] or "muse-code" in argv[0]: return argv return [] def pane_harness(socket_path, pane_id): """Agent harness owning a pane: "muse", "agy", or None. Matches the harness binary among the pane's processes (stable: independent of which child is in the foreground). """ for argv in _pane_tree_argvs(socket_path, pane_id): for harness, hints in HARNESS_HINTS: if any(h in argv[0] for h in hints): return harness return None # 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 (width 35 works when tall # enough; capture uses -J so wrapping is width-independent). Below # either bound the pane is flagged squeezed (see `box runtime layout` / # `spread`). MIN_APPROVAL_WIDTH = 35 MIN_APPROVAL_HEIGHT = 12 # Session naming convention (see NODES.md): ---- # separates node runtimes on the shared stable server, e.g. # pip--worker--01. Ad-hoc sessions carry no node and show "-". NODE_NAMES = ("muse", "pip", "646", "opm", "def", "dev") NODE_SESSION_RE = re.compile(r"^([A-Za-z0-9]+)--([A-Za-z0-9-]+)--([A-Za-z0-9]+)$") def node_from_session(session_name): """Node owning a tmux session per the naming convention. Returns the node name, or None for ad-hoc sessions and unknown node prefixes. Pure function for supervision display. """ if not session_name: return None m = NODE_SESSION_RE.match(session_name) if not m: return None node = m.group(1).lower() return node if node in NODE_NAMES else None def runtime_rows(socket_path, errors=None): """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. When the socket itself is unreachable and errors is a dict, the tmux failure is recorded as errors[socket_path] (one line) instead of silently yielding zero rows. """ 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: if errors is not None: errors[socket_path] = _tmux_err(r) return rows except Exception as e: if errors is not None: errors[socket_path] = str(e)[:160] or "tmux error" return rows saved_default = saved_default_profile() 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)) node = node_from_session(session) is_muse = "muse-bin" in cmd or "muse-code" in cmd posture = {"auto_approve": None, "flags": [], "profile": None, "mode": None, "bypass": None} 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 = watcher_alive(socket_path, pane_id) if posture["mode"] is None: effective = {"effective_mode": None, "effective_bypass": None} else: effective = effective_posture(posture, saved_default) rows.append({ "socket": socket_path, "session": session, "window": window, "pane": pane_id, "cmd": cmd, "pid": pane_pid, "is_muse": is_muse, "node": node, "width": pane_width, "height": pane_height, "squeezed": squeezed, "auto_approve": posture["auto_approve"], "approval_flags": posture["flags"], "permission_mode": posture["mode"], "permission_profile": posture["profile"], "permission_bypass": posture["bypass"], "effective_mode": effective["effective_mode"], "effective_bypass": effective["effective_bypass"], "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 _tmux_err(r): """First line of a failed tmux result, bounded.""" text = ((getattr(r, "stderr", "") or "") + "\n" + (getattr(r, "stdout", "") or "")).strip().split("\n") line = next((ln.strip() for ln in text if ln.strip()), "") return (line or "tmux error")[:160] def all_runtime_rows(sockets=None, errors=None): """runtime_rows across every known socket that exists. Sockets given explicitly but missing from the filesystem are reported via errors (when a dict) instead of silently skipped: callers that name a socket deserve to know it is absent. """ rows = [] explicit = sockets is not None for sock in sockets or KNOWN_SOCKETS: if not os.path.exists(sock): if errors is not None and explicit: errors[sock] = "no such socket" continue rows.extend(runtime_rows(sock, errors=errors)) return rows def pane_state(socket_path, pane_id): """Single-pane runtime row, or {"error": ...} when not found. Distinguishes a dead socket ("socket_unreachable", with the tmux failure in "detail") from a live socket without that pane ("no_such_pane"). """ errors = {} for row in runtime_rows(socket_path, errors=errors): if row["pane"] == pane_id: return row if errors.get(socket_path): return {"error": "socket_unreachable", "socket": socket_path, "pane": pane_id, "detail": errors[socket_path]} return {"error": "no_such_pane", "socket": socket_path, "pane": pane_id} class WatcherState: """Tracks prompt stability and once-per-prompt answering. When persist_path is set, answered sigs survive restarts (atomic JSON store): a daemon that restarts while an answered prompt is still visible must not answer it again (observed live: same sig re-answered minutes later, stray "1" landing in the input box). """ def __init__(self, persist_path=None, hourly_cap=None): self.pending_sig = None self.stable_count = 0 self.answered_sigs = {} self.answer_times = [] self.last_capped_sig = None self.hourly_cap = hourly_cap or MAX_ANSWERS_PER_HOUR self._persist_path = persist_path if persist_path: self._load_answered() def _load_answered(self): try: with open(self._persist_path) as f: data = json.load(f) except Exception: return if not isinstance(data, dict): return now = time.time() for sig, ts in data.items(): if (isinstance(sig, str) and isinstance(ts, (int, float)) and now - ts < ANSWERED_TTL_SECONDS): self.answered_sigs[sig] = ts def _save_answered(self): if not self._persist_path: return try: tmp = "%s.tmp.%d" % (self._persist_path, os.getpid()) with open(tmp, "w") as f: json.dump(self.answered_sigs, f) os.replace(tmp, self._persist_path) except Exception: pass def refresh_answered(self): """Merge on-disk answered sigs into memory. Never raises. Cross-process once-per-prompt: a peer watcher that answered after our startup persisted its sig; re-reading before we type suppresses the duplicate ('11' in the input box). """ if not self._persist_path: return try: with open(self._persist_path) as f: data = json.load(f) except Exception: return if not isinstance(data, dict): return now = time.time() for sig, ts in data.items(): if (isinstance(sig, str) and isinstance(ts, (int, float)) and now - ts < ANSWERED_TTL_SECONDS): if sig not in self.answered_sigs: self.answered_sigs[sig] = ts def try_claim(self, sig, now=None): """Atomically claim a sig for this process. True iff we won. O_CREAT|O_EXCL makes the first claimer win even when two watchers reach the same stable prompt in the same poll window; the loser suppresses instead of double-typing. Claims expire after CLAIM_TTL_SECONDS (concurrent window only; stuck-dialog recovery is governed by the answered store's longer TTL), so a winner that dies between claim and send wedges at most briefly. Memory-only states (no persist path) always win. Never raises. """ if not self._persist_path: return True now = time.time() if now is None else now base = os.path.basename(self._persist_path) directory = os.path.dirname(self._persist_path) or STATE_DIR # Prune expired claims for this pane (best-effort). try: for name in os.listdir(directory): if not name.startswith(base + ".") or not name.endswith(".claim"): continue p = os.path.join(directory, name) try: with open(p) as f: rec = json.load(f) ts = float(rec.get("ts", 0)) except Exception: ts = 0 try: if now - ts >= CLAIM_TTL_SECONDS: os.remove(p) except OSError: pass except Exception: pass path = "%s.%s.claim" % (self._persist_path, sig) try: fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) except FileExistsError: return False except OSError: return True # claim store unavailable: fail open, send once try: os.write(fd, json.dumps({"pid": os.getpid(), "ts": now}).encode()) except Exception: pass try: os.close(fd) except Exception: pass return True 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) >= self.hourly_cap 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 self._save_answered() 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): """Capture pane text with wrapped rows joined (-J). -J makes matching width-independent: narrow panes wrap the same dialog onto more physical rows, which otherwise pushes cue/option spans apart and breaks the matcher. """ try: r = _tmux(socket_path, "capture-pane", "-p", "-J", "-t", pane_id, "-S", "-%d" % history, timeout=5) if r.returncode != 0: return None return r.stdout except Exception: return None def _tail_composer_line(text, window=COMPOSER_TAIL_WINDOW): """Last input-prompt-led line in the trailing window, or None. Transcript history may hold older input echoes of submitted messages; the LAST prompt-led row is the live composer input. None when no such row shows (shell prompts, menus with no input row). """ lines = (text or "").split("\n") while lines and not lines[-1].strip(): lines.pop() for line in reversed(lines[-window:]): if OPEN_PROMPT_RE.match(line): return line return None def _composer_shows_letter(text, letter): """True when the live composer input is exactly our letter. Exact match only: stale or concurrent input ("ABC", placeholder hints) must never read as ours, so a retry can only re-submit the known newline-bug shape (our letter sitting unsubmitted). """ if not letter: return False line = _tail_composer_line(text) if line is None: return False return re.match(r"^\s*\u276f\s*%s\s*$" % re.escape(letter), line) is not None def _await_render(socket_path, pane_id, before): """True once the screen differs from `before`. Never raises. A changed screen proves the app consumed the typed text, so whatever we send next is a separate input event. Unchanged past the timeout (slow pane, dead pane, tmux hiccup) fails open: the caller still sends Enter on the blind timing. """ if before is None: return False try: deadline = time.time() + VERIFY_TEXT_TIMEOUT while time.time() < deadline: now = capture_pane(socket_path, pane_id) if now is not None and now != before: return True time.sleep(VERIFY_POLL_SECONDS) except Exception: pass return False def send_answer(socket_path, pane_id, letter="A", enter=True, kind=None, sig=None): """Type the choice key (+ Enter unless enter=False). Returns (ok, detail): ok is False only when tmux refused a send or a composer answer is still sitting unsubmitted after one retry; detail is {"verified": True/False/None, "retried": bool} for logs/audit. Composer kinds (letter/numbered/yn/explicit-phrase: the answer goes to a text input) are capture-verified: the text must render on screen before Enter goes out (proving the app consumed it as its own input event — a back-to-back burst arrives as paste and lands a newline instead of submitting), and after Enter the prompt must clear or the composer must empty, else Enter goes once more. Timeouts fail open to the blind send so a slow pane still gets its answer. Menu kinds (muse-approval/interview/collapsed: single-key widgets that never render text) keep the blind gap send, which is proven there. """ blind = {"verified": None, "retried": False} try: if not enter: r = _tmux(socket_path, "send-keys", "-t", pane_id, letter, timeout=5) return (r.returncode == 0), dict(blind) verify = kind in COMPOSER_KINDS before = capture_pane(socket_path, pane_id) if verify else None r1 = _tmux(socket_path, "send-keys", "-l", "-t", pane_id, letter, timeout=5) if r1.returncode != 0: return False, dict(blind) verified = _await_render(socket_path, pane_id, before) \ if verify else None time.sleep(SUBMIT_GAP_SECONDS) r2 = _tmux(socket_path, "send-keys", "-t", pane_id, "Enter", timeout=5) if r2.returncode != 0: return False, {"verified": verified, "retried": False} if not verify or sig is None: return True, {"verified": verified, "retried": False} # Post-verify: same sig + our letter still in the composer means # the Enter landed as a newline; one more Enter submits it. Same # sig with a clear composer is a lingering transcript, not a miss. time.sleep(POST_ENTER_SETTLE) fresh = capture_pane(socket_path, pane_id) if fresh is None: return True, {"verified": verified, "retried": False} m = find_choice_prompt(fresh) if m is None or m["sig"] != sig: return True, {"verified": verified, "retried": False} if not _composer_shows_letter(fresh, letter): return True, {"verified": verified, "retried": False} r3 = _tmux(socket_path, "send-keys", "-t", pane_id, "Enter", timeout=5) if r3.returncode != 0: return False, {"verified": verified, "retried": True} time.sleep(POST_ENTER_SETTLE) again = capture_pane(socket_path, pane_id) stuck = (again is not None and _composer_shows_letter(again, letter)) return (not stuck), {"verified": verified, "retried": True} except Exception: return False, {"verified": None, "retried": False} def _load_rules(log=None): """Load the D0 rules dictionary, cached by (mtime, size). Never raises: a missing or broken file means no rules (fail open per D4).""" global _RULES_CACHE try: st = os.stat(RULES_FILE) key = (st.st_mtime, st.st_size) except OSError: key = "missing" if _RULES_CACHE["key"] == key: return _RULES_CACHE["rules"] rules = [] if key != "missing": try: with open(RULES_FILE) as f: data = json.load(f) rules = [r for r in data.get("rules", []) if isinstance(r, dict) and r.get("id")] except Exception as e: if log is not None: try: log.log("warn", "rules file unreadable, failing open", error="%r" % (e,)) except Exception: pass rules = [] _RULES_CACHE = {"key": key, "rules": rules} return rules def _as_list(v): return v if isinstance(v, list) else [v] def _rule_matches(rule, match): kind = rule.get("kind") if kind is not None and match["kind"] not in _as_list(kind): return False token = rule.get("token") if token is not None: if match["kind"] != "explicit-phrase": return False if match["key"] not in _as_list(token): return False cmd = rule.get("command") if cmd is not None: ctx = match.get("context") or match.get("options", []) try: if not re.search(cmd, "\n".join(ctx)): return False except re.error: return False text = rule.get("text") if text is not None: hay = "\n".join(match.get("options", []) + [match.get("cue") or ""]) try: if not re.search(text, hay): return False except re.error: return False return True def evaluate_rules(match, log=None): """First matching rule wins -> (decision, rule). No match -> approve. D2: deny rules are skipped for question kinds (questions always resolve top-choice). Collapsed approvals have no visible No option, so deny downgrades to hold there rather than auto-approving something a rule flagged risky. """ for rule in _load_rules(log=log): if not _rule_matches(rule, match): continue d = rule.get("decision", "approve") if d not in ("approve", "deny", "hold"): d = "approve" if d == "deny": if match["kind"] in NEGATIVE_KEYS: return "deny", rule if match["kind"] in QUESTION_KINDS: continue downgraded = dict(rule) downgraded["downgraded_from"] = "deny" return "hold", downgraded return d, rule return "approve", None def holdfile_for(socket_path, pane_id): return os.path.join( STATE_DIR, "%s-%s-%s.held.json" % (FILE_PREFIX, slug_socket(socket_path), clean_pane(pane_id))) def write_hold(socket_path, pane_id, rec): try: with open(holdfile_for(socket_path, pane_id), "w") as f: json.dump(rec, f) return True except Exception: return False def read_hold(socket_path, pane_id): try: with open(holdfile_for(socket_path, pane_id)) as f: rec = json.load(f) return rec if isinstance(rec, dict) and rec.get("sig") else None except Exception: return None def clear_hold(socket_path, pane_id): try: os.remove(holdfile_for(socket_path, pane_id)) except OSError: pass def list_holds(): """All holdfiles, pruning ones long expired with no watcher to own them.""" out = [] try: names = os.listdir(STATE_DIR) except OSError: return out now = time.time() for name in names: if not name.startswith(FILE_PREFIX + "-") or not name.endswith(".held.json"): continue path = os.path.join(STATE_DIR, name) try: with open(path) as f: rec = json.load(f) except Exception: continue if not isinstance(rec, dict) or not rec.get("sig"): continue try: expired_ago = now - float(rec.get("held_until", 0)) except (TypeError, ValueError): expired_ago = 0 if expired_ago > 300: try: os.remove(path) except OSError: pass continue rec["_file"] = name out.append(rec) return out def _peer_suppressed(state, sig, now, log): """True when a peer watcher already owns sig; suppress our send. Checks the shared on-disk answered store first (peer answered earlier and persisted), then attempts an atomic claim (peer racing us in the same window). On suppression the pending prompt is reset so later polls re-observe cleanly; answered-store hits are already merged into memory, claim-loss is not recorded (peer's send owns it, and its claim expires quickly if it dies). Never raises; memory-only states never suppress. """ try: state.refresh_answered() except Exception: pass try: ts = state.answered_sigs.get(sig) if ts is not None and now - ts < ANSWERED_TTL_SECONDS: try: log.log("info", "duplicate suppressed (peer answered)", sig=sig) except Exception: pass state.pending_sig = None state.stable_count = 0 return True except Exception: pass try: if not state.try_claim(sig, now): try: log.log("info", "duplicate suppressed (peer claimed)", sig=sig) except Exception: pass state.pending_sig = None state.stable_count = 0 return True except Exception: pass return False def hold_renews_forever(hold, kind): """True when a hold renews instead of expiring to approve. Coordinator-gated kinds (grill interviews) and every agy hold (unproven sends): release only by operator directive. """ return (kind in HOLD_FOREVER_KINDS or (hold or {}).get("harness") == "agy") def _check_hold(socket_path, pane_id, state, match, now, log): """Rule-hold gate. Returns None (fresh: evaluate rules), "released" (hold over: approve WITHOUT re-evaluating, else expiry would re-hold forever), "held", "denied", or "duplicate-suppressed". The holdfile is the single source of truth (crash-safe, box-visible): present + fresh => suppress; directive deny => deny now; expired or operator-cleared => release back to approve. """ sig = match["sig"] hf = read_hold(socket_path, pane_id) if hf is None: return None if hf.get("sig") != sig: clear_hold(socket_path, pane_id) # stale hold for a gone prompt return None if hf.get("directive") == "approve": clear_hold(socket_path, pane_id) log.log("info", "hold released by operator, answering", sig=sig) return "released" if hf.get("directive") == "deny": neg = NEGATIVE_KEYS.get(match["kind"]) if neg is None: hf["directive"] = None write_hold(socket_path, pane_id, hf) log.log("warn", "resolve-deny refused: D2 never denies questions", sig=sig, kind=match["kind"]) return "held" if _peer_suppressed(state, sig, now, log): return "duplicate-suppressed" ok, detail = send_answer(socket_path, pane_id, neg, enter=True, kind=match["kind"], sig=sig) clear_hold(socket_path, pane_id) state.record_answer(sig, now) log.log("info" if ok else "error", "denied %s (operator resolve)" % neg, sig=sig, ok=ok, kind=match["kind"], key=neg, verified=detail["verified"], retried=detail["retried"]) audit("muse-choice-denied", name="%s:%s" % (os.path.basename(socket_path), pane_id), extra={"socket": socket_path, "pane": pane_id, "sig": sig, "ok": ok, "kind": match["kind"], "key": neg, "via": "resolve", "verified": detail["verified"], "retried": detail["retried"]}) return "denied" try: expired = now >= float(hf.get("held_until", 0)) except (TypeError, ValueError): expired = True if expired and hold_renews_forever(hf, match["kind"]): # Coordinator-gated kinds, and every agy hold, never expire # to approve: renew the window while the prompt persists. # Release only by operator directive, or when the prompt # scrolls away (sig mismatch) or the operator answers by # hand. Renewal failures keep holding; they never fail open # to approve. try: hf["held_until"] = now + HOLD_WINDOW_SECONDS hf["renewals"] = int(hf.get("renewals", 0) or 0) + 1 write_hold(socket_path, pane_id, hf) except Exception: pass else: why = ("agy hold-all" if hf.get("harness") == "agy" else "coordinator gate") log.log("info", "hold renewed (%s, never auto)" % why, sig=sig, kind=match["kind"], renewals=hf["renewals"]) return "held" if expired: clear_hold(socket_path, pane_id) log.log("info", "hold expired, releasing to approve", sig=sig) audit("muse-choice-hold-expired", name="%s:%s" % (os.path.basename(socket_path), pane_id), extra={"socket": socket_path, "pane": pane_id, "sig": sig, "kind": match["kind"], "rule": hf.get("rule")}) return "released" return "held" 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() gate = _check_hold(socket_path, pane_id, state, match, now, log) \ if match is not None else None if gate in ("held", "denied", "duplicate-suppressed"): if gate in ("denied", "duplicate-suppressed"): state.pending_sig = None state.stable_count = 0 return gate skip_eval = (gate == "released") 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 pane_holds_for_optout(socket_path, 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" if pane_harness(socket_path, pane_id) == "agy" and not skip_eval: # Hold-all: agy auto-sends are unproven (its native menus # may ignore digit keys), so every agy prompt holds for # coordinator resolve instead of auto-answering. The hold # renews forever; a released hold (resolve-approve) falls # through and sends the kind's key with a human in the # loop -- hence the skip_eval guard (cf. permissions). until = now + HOLD_WINDOW_SECONDS write_hold(socket_path, pane_id, { "sig": match["sig"], "kind": match["kind"], "key": match["key"], "text": (match["cue"] or "")[:200], "rule": AGY_HOLD_RULE, "reason": ("agy harness: sends unproven; " "coordinator resolve required"), "downgraded_from": None, "harness": "agy", "held_until": until, "directive": None, "socket": socket_path, "pane": pane_id}) log.log("info", "held: agy harness (hold-all)", sig=match["sig"], kind=match["kind"]) if not dry_run: audit("muse-choice-held", name="%s:%s" % (os.path.basename(socket_path), pane_id), extra={"socket": socket_path, "pane": pane_id, "sig": match["sig"], "kind": match["kind"], "rule": AGY_HOLD_RULE, "harness": "agy", "held_until": until}) return "held" if match["kind"] == "permissions" and not skip_eval: # Builtin hold: a mode change persists as the default for # new sessions and exec runs, so no rule may auto-answer # it -- the operator drives the TUI by hand. Rules are # not consulted for this kind. until = now + HOLD_WINDOW_SECONDS write_hold(socket_path, pane_id, { "sig": match["sig"], "kind": "permissions", "key": None, "text": (match["cue"] or "")[:200], "rule": "builtin-permissions", "reason": ("permission mode screen; operator drives " "the TUI by hand"), "downgraded_from": None, "held_until": until, "directive": None, "socket": socket_path, "pane": pane_id}) log.log("info", "held: /permissions screen (builtin)", sig=match["sig"], kind="permissions") if not dry_run: audit("muse-choice-held", name="%s:%s" % (os.path.basename(socket_path), pane_id), extra={"socket": socket_path, "pane": pane_id, "sig": match["sig"], "kind": "permissions", "rule": "builtin-permissions", "held_until": until}) return "held" if skip_eval: decision, rule = "approve", None via = "hold-released" else: decision, rule = evaluate_rules(match, log) via = "direct" key = match["key"] if decision == "deny": neg = NEGATIVE_KEYS[match["kind"]] if dry_run: log.log("info", "dry-run would deny %s" % neg, sig=match["sig"], kind=match["kind"], key=neg, rule=rule["id"] if rule else None) state.record_answer(match["sig"], now) return "dry-denied" if _peer_suppressed(state, match["sig"], now, log): return "duplicate-suppressed" ok, detail = send_answer(socket_path, pane_id, neg, enter=True, kind=match["kind"], sig=match["sig"]) state.record_answer(match["sig"], now) log.log("info" if ok else "error", "denied %s" % neg, sig=match["sig"], ok=ok, kind=match["kind"], key=neg, rule=rule["id"] if rule else None, verified=detail["verified"], retried=detail["retried"]) audit("muse-choice-denied", 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": neg, "via": "rule", "rule": rule["id"] if rule else None, "verified": detail["verified"], "retried": detail["retried"]}) return "denied" if decision == "hold": until = now + HOLD_WINDOW_SECONDS write_hold(socket_path, pane_id, { "sig": match["sig"], "kind": match["kind"], "key": key, "text": (match["cue"] or "")[:200], "rule": rule["id"] if rule else None, "reason": (rule.get("reason") if rule else None) or "", "downgraded_from": (rule.get("downgraded_from") if rule else None), "held_until": until, "directive": None, "socket": socket_path, "pane": pane_id}) log.log("info", "held by rule %s" % (rule["id"] if rule else "?"), sig=match["sig"], kind=match["kind"], rule=rule["id"] if rule else None, reason=(rule.get("reason") if rule else None) or "") if not dry_run: audit("muse-choice-held", name="%s:%s" % (os.path.basename(socket_path), pane_id), extra={"socket": socket_path, "pane": pane_id, "sig": match["sig"], "kind": match["kind"], "rule": rule["id"] if rule else None, "held_until": until}) return "held" 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" if match["kind"] == "permissions": # Released (operator resolve or hold expiry): still send # nothing -- the operator drives mode changes by hand. # Record so this screen stops holding. log.log("info", "permissions released to operator (no keys sent)", sig=match["sig"], cue=match["cue"]) state.record_answer(match["sig"], now) 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": True, "kind": "permissions", "key": None, "options": match["options"], "cue": match["cue"], "via": via, "rule": rule["id"] if rule else None}) return "answered" if _peer_suppressed(state, match["sig"], now, log): return "duplicate-suppressed" ok, detail = send_answer(socket_path, pane_id, key, enter=match.get("enter", True), kind=match["kind"], sig=match["sig"]) 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"], verified=detail["verified"], retried=detail["retried"]) 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"], "via": via, "rule": rule["id"] if rule else None, "verified": detail["verified"], "retried": detail["retried"]}) 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 _log_posture(socket_path, pane_id, log, saved_default=None): """Log the pane's permission posture once at watcher start. The watcher answers with per-choice logging in every mode (that trail is the default path and informs policy); an effective bypass posture (yolo / approval disabled / unrestricted saved default with no explicit flags) additionally gets a box audit record, since the session then makes choices outside the trail. Never raises. """ try: posture = muse_approval_flags(pane_muse_argv(socket_path, pane_id)) except Exception: return try: effective = effective_posture(posture, saved_default) except Exception: effective = {"effective_mode": posture["mode"], "effective_bypass": posture["bypass"]} try: log.log("info", "pane posture", mode=posture["mode"], bypass=posture["bypass"], flags=posture["flags"], effective_mode=effective["effective_mode"], effective_bypass=effective["effective_bypass"]) except Exception: pass try: if (effective["effective_bypass"] or effective["effective_mode"] not in ("default", None)): audit("muse-choice-posture", name="%s:%s" % (os.path.basename(socket_path), pane_id), extra={"socket": socket_path, "pane": pane_id, "mode": posture["mode"], "profile": posture["profile"], "bypass": posture["bypass"], "flags": posture["flags"], "effective_mode": effective["effective_mode"], "effective_bypass": effective["effective_bypass"]}) except Exception: pass 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( persist_path=answered_file_for(socket_path, pane_id), hourly_cap=hourly_cap_for(socket_path)) log.log("info", "watcher started", socket=socket_path, pane=pane_id, dry_run=dry_run, pid=os.getpid()) _log_posture(socket_path, pane_id, log, saved_default=saved_default_profile()) 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 watcher_alive(socket_path, pane_id): """Pid of the live watcher for a pane, pidfile or orphan. is_running covers the normal pidfile case; _watch_procs catches an identical watcher alive with a lost pidfile (/tmp cleaned under it). Starters must consult this (not is_running alone) or they spawn a second daemon onto the same pane (double answers). Never raises; None means no live watcher. """ try: pid = is_running(socket_path, pane_id) if pid: return pid except Exception: pass try: for proc in _watch_procs(): if proc["socket"] == socket_path and proc["pane"] == pane_id: return proc["pid"] except Exception: 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) _PIDFILE_LOCK_FH = None def _claim_pidfile(pidfile): """Claim a pidfile for this process. Returns False if another starter holds it. Kernel-enforced: the winner holds an exclusive flock for its whole lifetime, so concurrent box + timer starts can never pile two daemons onto one pane (double answers, '11' in the input box). Call _release_pidfile() on exit.""" global _PIDFILE_LOCK_FH me = os.getpid() try: fh = open(pidfile, "a+") except OSError: return False try: fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) except (OSError, IOError): fh.close() return False try: fh.seek(0) content = fh.read().strip() other = int(content) if content else None except Exception: other = None if other and other != me and _pid_alive(other) and _pid_is_watcher(other): # Live foreign owner (e.g. a daemon from before locking existed, # or a pid recycled into a watcher): yield to it. try: fcntl.flock(fh, fcntl.LOCK_UN) except Exception: pass fh.close() return False try: fh.seek(0) fh.truncate() fh.write(str(me)) fh.flush() except Exception: pass if _PIDFILE_LOCK_FH is not None: try: _PIDFILE_LOCK_FH.close() except Exception: pass _PIDFILE_LOCK_FH = fh return True def _release_pidfile(pidfile): """Drop the claim taken by _claim_pidfile: remove the pidfile only if we still own it, then release the lock. Never raises.""" global _PIDFILE_LOCK_FH 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 _PIDFILE_LOCK_FH is not None: try: fcntl.flock(_PIDFILE_LOCK_FH, fcntl.LOCK_UN) except Exception: pass try: _PIDFILE_LOCK_FH.close() except Exception: pass _PIDFILE_LOCK_FH = None def start_watcher(socket_path, pane_id, dry_run=False): pid = watcher_alive(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 = watcher_alive(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 agent_panes(socket_path): """Pane ids on a socket whose current command looks like an agent. Every agent harness is watched by default; extend AGENT_PANE_HINTS as new harnesses appear. """ 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 any(h in parts[1] for h in AGENT_PANE_HINTS): out.append(parts[0]) return out muse_panes = agent_panes # backward-compatible alias def wait_for_session_pane(socket_path, session, timeout=10, interval=0.5): """True once the session has a pane running an agent command. Launch-time boot race: `new-session` returns before the agent binary is visible as pane_current_command, so a reconcile fired immediately would find no agent panes and start no watcher. Bounded and never fatal: on timeout just proceed to reconcile (the timer heals the rest). Never raises. """ try: deadline = time.time() + timeout except Exception: return False while True: try: r = _tmux(socket_path, "list-panes", "-a", "-F", "#{session_name} #{pane_id} #{pane_current_command}", timeout=10) except Exception: return False if r.returncode == 0: for line in r.stdout.split("\n"): parts = line.strip().split(None, 2) if len(parts) == 3 and parts[0] == session and any( h in parts[2] for h in AGENT_PANE_HINTS): return True try: if time.time() >= deadline: return False time.sleep(interval) except Exception: return False 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 watcher_alive(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 = agent_panes(sock) if not panes: results.append({"socket": sock, "status": "no_muse_panes"}) for pane in panes: if watcher_alive(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 agent_panes(sock): if watcher_alive(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 = watcher_alive(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 = watcher_alive(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: _release_pidfile(pidfile) 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())