Files
box/bin/muse_choice_watcher.py
T

1457 lines
53 KiB
Python
Raw Normal View History

#!/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
# $ <command echo, often wrapped across lines>
# > 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
# $ <first rows...>
# ... 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):
# <question...?>
# > 1. First option (Recommended) <desc...>
# 2. Second option <desc...>
# 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())