2026-10-07 00:25:46 +00:00
|
|
|
|
#!/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,
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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).
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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
|
2026-10-07 01:50:18 +00:00
|
|
|
|
import fcntl
|
2026-10-07 00:25:46 +00:00
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import re
|
2026-10-09 23:13:43 +00:00
|
|
|
|
import shlex
|
|
|
|
|
|
import shutil
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
# 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",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
POLL_INTERVAL = 0.5
|
|
|
|
|
|
STABILITY_POLLS = 2
|
|
|
|
|
|
MAX_ANSWERS_PER_HOUR = 20
|
2026-10-09 23:13:43 +00:00
|
|
|
|
FLEET_MAX_ANSWERS_PER_HOUR = 200
|
2026-10-07 00:25:46 +00:00
|
|
|
|
ANSWERED_TTL_SECONDS = 600 # identical prompt back after 10m => stuck, allow one recovery answer
|
2026-10-07 01:50:18 +00:00
|
|
|
|
CLAIM_TTL_SECONDS = 30 # concurrent-claim window: bounds wedge if winner dies pre-send
|
2026-10-07 00:49:23 +00:00
|
|
|
|
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
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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
|
2026-10-07 00:49:23 +00:00
|
|
|
|
_RULES_CACHE = {"key": None, "rules": []}
|
2026-10-07 00:25:46 +00:00
|
|
|
|
LOG_MAX_BYTES = 1_000_000
|
|
|
|
|
|
HEARTBEAT_SECONDS = 60
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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)
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
# 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")
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
# /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
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
# 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))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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)
|
2026-10-09 23:13:43 +00:00
|
|
|
|
tail = _option_block_end(window, end, OPTION_RE)
|
|
|
|
|
|
hi = min(len(window), tail + 1 + CUE_BELOW_ROWS)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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)
|
2026-10-09 23:13:43 +00:00
|
|
|
|
tail = _option_block_end(window, end, NUMBERED_OPT_RE)
|
|
|
|
|
|
hi = min(len(window), tail + 1 + CUE_BELOW_ROWS)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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,
|
2026-10-07 00:49:23 +00:00
|
|
|
|
"cue": cue, "context": context,
|
|
|
|
|
|
"start": cue_idx, "end": no_idx}
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-10-07 00:49:23 +00:00
|
|
|
|
"context": context, "start": cue_idx, "end": end}
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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 <X> 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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
def find_choice_prompt(text, tail_window=TAIL_WINDOW):
|
|
|
|
|
|
"""Detect a Muse prompt awaiting reply.
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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").
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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):
|
2026-10-07 01:50:18 +00:00
|
|
|
|
"""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).
|
|
|
|
|
|
"""
|
2026-10-07 00:25:46 +00:00
|
|
|
|
flags = []
|
|
|
|
|
|
argv = cmd_argv or []
|
|
|
|
|
|
if "--yolo" in argv:
|
|
|
|
|
|
flags.append("yolo")
|
|
|
|
|
|
if "--disable-approval" in argv:
|
|
|
|
|
|
flags.append("disable-approval")
|
2026-10-07 01:50:18 +00:00
|
|
|
|
if "--disable-sandbox" in argv:
|
|
|
|
|
|
flags.append("disable-sandbox")
|
|
|
|
|
|
if "--trust-workspace" in argv:
|
|
|
|
|
|
flags.append("trust-workspace")
|
|
|
|
|
|
profile = None
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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])
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
auto = ("yolo" in flags or "disable-approval" in flags
|
|
|
|
|
|
or "approval-mode=never" in flags)
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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}
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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/<name>. 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)."""
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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 []
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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]:
|
2026-10-07 00:25:46 +00:00
|
|
|
|
return argv
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
# Minimum pane geometry for reliable approval rendering. Empirically
|
|
|
|
|
|
# derived: a 35x7 tile drops approval text the matcher needs, while
|
2026-10-07 01:50:18 +00:00
|
|
|
|
# 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
|
2026-10-07 00:25:46 +00:00
|
|
|
|
MIN_APPROVAL_HEIGHT = 12
|
|
|
|
|
|
|
2026-10-07 00:49:23 +00:00
|
|
|
|
# Session naming convention (see NODES.md): <node>--<role>--<id>
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
def runtime_rows(socket_path, errors=None):
|
2026-10-07 00:25:46 +00:00
|
|
|
|
"""One row per pane: identity, approval posture, live state, watcher.
|
|
|
|
|
|
|
|
|
|
|
|
Rows are JSON-serializable dicts for `box runtime list` and external
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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.
|
2026-10-07 00:25:46 +00:00
|
|
|
|
"""
|
|
|
|
|
|
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:
|
2026-10-09 23:13:43 +00:00
|
|
|
|
if errors is not None:
|
|
|
|
|
|
errors[socket_path] = _tmux_err(r)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
return rows
|
2026-10-09 23:13:43 +00:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
if errors is not None:
|
|
|
|
|
|
errors[socket_path] = str(e)[:160] or "tmux error"
|
2026-10-07 00:25:46 +00:00
|
|
|
|
return rows
|
2026-10-09 23:13:43 +00:00
|
|
|
|
saved_default = saved_default_profile()
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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))
|
2026-10-07 00:49:23 +00:00
|
|
|
|
node = node_from_session(session)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
is_muse = "muse-bin" in cmd or "muse-code" in cmd
|
2026-10-07 01:50:18 +00:00
|
|
|
|
posture = {"auto_approve": None, "flags": [], "profile": None,
|
|
|
|
|
|
"mode": None, "bypass": None}
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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 {}
|
2026-10-07 01:50:18 +00:00
|
|
|
|
watcher_pid = watcher_alive(socket_path, pane_id)
|
2026-10-09 23:13:43 +00:00
|
|
|
|
if posture["mode"] is None:
|
|
|
|
|
|
effective = {"effective_mode": None, "effective_bypass": None}
|
|
|
|
|
|
else:
|
|
|
|
|
|
effective = effective_posture(posture, saved_default)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
rows.append({
|
|
|
|
|
|
"socket": socket_path, "session": session,
|
|
|
|
|
|
"window": window, "pane": pane_id, "cmd": cmd,
|
|
|
|
|
|
"pid": pane_pid, "is_muse": is_muse,
|
2026-10-07 00:49:23 +00:00
|
|
|
|
"node": node,
|
2026-10-07 00:25:46 +00:00
|
|
|
|
"width": pane_width, "height": pane_height,
|
|
|
|
|
|
"squeezed": squeezed,
|
|
|
|
|
|
"auto_approve": posture["auto_approve"],
|
|
|
|
|
|
"approval_flags": posture["flags"],
|
2026-10-07 01:50:18 +00:00
|
|
|
|
"permission_mode": posture["mode"],
|
|
|
|
|
|
"permission_profile": posture["profile"],
|
|
|
|
|
|
"permission_bypass": posture["bypass"],
|
2026-10-09 23:13:43 +00:00
|
|
|
|
"effective_mode": effective["effective_mode"],
|
|
|
|
|
|
"effective_bypass": effective["effective_bypass"],
|
2026-10-07 00:25:46 +00:00
|
|
|
|
"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")]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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.
|
|
|
|
|
|
"""
|
2026-10-07 00:25:46 +00:00
|
|
|
|
rows = []
|
2026-10-09 23:13:43 +00:00
|
|
|
|
explicit = sockets is not None
|
2026-10-07 00:25:46 +00:00
|
|
|
|
for sock in sockets or KNOWN_SOCKETS:
|
|
|
|
|
|
if not os.path.exists(sock):
|
2026-10-09 23:13:43 +00:00
|
|
|
|
if errors is not None and explicit:
|
|
|
|
|
|
errors[sock] = "no such socket"
|
2026-10-07 00:25:46 +00:00
|
|
|
|
continue
|
2026-10-09 23:13:43 +00:00
|
|
|
|
rows.extend(runtime_rows(sock, errors=errors))
|
2026-10-07 00:25:46 +00:00
|
|
|
|
return rows
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pane_state(socket_path, pane_id):
|
2026-10-09 23:13:43 +00:00
|
|
|
|
"""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):
|
2026-10-07 00:25:46 +00:00
|
|
|
|
if row["pane"] == pane_id:
|
|
|
|
|
|
return row
|
2026-10-09 23:13:43 +00:00
|
|
|
|
if errors.get(socket_path):
|
|
|
|
|
|
return {"error": "socket_unreachable", "socket": socket_path,
|
|
|
|
|
|
"pane": pane_id, "detail": errors[socket_path]}
|
2026-10-07 00:25:46 +00:00
|
|
|
|
return {"error": "no_such_pane", "socket": socket_path,
|
|
|
|
|
|
"pane": pane_id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WatcherState:
|
2026-10-07 01:50:18 +00:00
|
|
|
|
"""Tracks prompt stability and once-per-prompt answering.
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
def __init__(self, persist_path=None, hourly_cap=None):
|
2026-10-07 00:25:46 +00:00
|
|
|
|
self.pending_sig = None
|
|
|
|
|
|
self.stable_count = 0
|
|
|
|
|
|
self.answered_sigs = {}
|
|
|
|
|
|
self.answer_times = []
|
|
|
|
|
|
self.last_capped_sig = None
|
2026-10-09 23:13:43 +00:00
|
|
|
|
self.hourly_cap = hourly_cap or MAX_ANSWERS_PER_HOUR
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-10-09 23:13:43 +00:00
|
|
|
|
return len(self.answer_times) >= self.hourly_cap
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-07 01:50:18 +00:00
|
|
|
|
self._save_answered()
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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):
|
2026-10-07 01:50:18 +00:00
|
|
|
|
"""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.
|
|
|
|
|
|
"""
|
2026-10-07 00:25:46 +00:00
|
|
|
|
try:
|
2026-10-07 01:50:18 +00:00
|
|
|
|
r = _tmux(socket_path, "capture-pane", "-p", "-J", "-t", pane_id,
|
|
|
|
|
|
"-S", "-%d" % history, timeout=5)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
if r.returncode != 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return r.stdout
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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:
|
2026-10-07 00:25:46 +00:00
|
|
|
|
return False
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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}
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:49:23 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:49:23 +00:00
|
|
|
|
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
|
2026-10-07 01:50:18 +00:00
|
|
|
|
re-hold forever), "held", "denied", or "duplicate-suppressed".
|
2026-10-07 00:49:23 +00:00
|
|
|
|
|
|
|
|
|
|
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"
|
2026-10-07 01:50:18 +00:00
|
|
|
|
if _peer_suppressed(state, sig, now, log):
|
|
|
|
|
|
return "duplicate-suppressed"
|
2026-10-09 23:13:43 +00:00
|
|
|
|
ok, detail = send_answer(socket_path, pane_id, neg, enter=True,
|
|
|
|
|
|
kind=match["kind"], sig=sig)
|
2026-10-07 00:49:23 +00:00
|
|
|
|
clear_hold(socket_path, pane_id)
|
|
|
|
|
|
state.record_answer(sig, now)
|
|
|
|
|
|
log.log("info" if ok else "error", "denied %s (operator resolve)" % neg,
|
2026-10-09 23:13:43 +00:00
|
|
|
|
sig=sig, ok=ok, kind=match["kind"], key=neg,
|
|
|
|
|
|
verified=detail["verified"], retried=detail["retried"])
|
2026-10-07 00:49:23 +00:00
|
|
|
|
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,
|
2026-10-09 23:13:43 +00:00
|
|
|
|
"via": "resolve", "verified": detail["verified"],
|
|
|
|
|
|
"retried": detail["retried"]})
|
2026-10-07 00:49:23 +00:00
|
|
|
|
return "denied"
|
|
|
|
|
|
try:
|
|
|
|
|
|
expired = now >= float(hf.get("held_until", 0))
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
expired = True
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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"
|
2026-10-07 00:49:23 +00:00
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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()
|
2026-10-07 00:49:23 +00:00
|
|
|
|
gate = _check_hold(socket_path, pane_id, state, match, now, log) \
|
|
|
|
|
|
if match is not None else None
|
2026-10-07 01:50:18 +00:00
|
|
|
|
if gate in ("held", "denied", "duplicate-suppressed"):
|
|
|
|
|
|
if gate in ("denied", "duplicate-suppressed"):
|
2026-10-07 00:49:23 +00:00
|
|
|
|
state.pending_sig = None
|
|
|
|
|
|
state.stable_count = 0
|
|
|
|
|
|
return gate
|
|
|
|
|
|
skip_eval = (gate == "released")
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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"
|
2026-10-09 23:13:43 +00:00
|
|
|
|
if pane_holds_for_optout(socket_path,
|
|
|
|
|
|
pane_muse_argv(socket_path, pane_id)):
|
2026-10-07 00:25:46 +00:00
|
|
|
|
log.log("info", "held: pane opted out via launch flags",
|
|
|
|
|
|
sig=match["sig"], kind=match["kind"])
|
|
|
|
|
|
state.record_answer(match["sig"], now)
|
|
|
|
|
|
return "held"
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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"
|
2026-10-07 00:49:23 +00:00
|
|
|
|
if skip_eval:
|
|
|
|
|
|
decision, rule = "approve", None
|
|
|
|
|
|
via = "hold-released"
|
|
|
|
|
|
else:
|
|
|
|
|
|
decision, rule = evaluate_rules(match, log)
|
|
|
|
|
|
via = "direct"
|
2026-10-07 00:25:46 +00:00
|
|
|
|
key = match["key"]
|
2026-10-07 00:49:23 +00:00
|
|
|
|
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"
|
2026-10-07 01:50:18 +00:00
|
|
|
|
if _peer_suppressed(state, match["sig"], now, log):
|
|
|
|
|
|
return "duplicate-suppressed"
|
2026-10-09 23:13:43 +00:00
|
|
|
|
ok, detail = send_answer(socket_path, pane_id, neg, enter=True,
|
|
|
|
|
|
kind=match["kind"],
|
|
|
|
|
|
sig=match["sig"])
|
2026-10-07 00:49:23 +00:00
|
|
|
|
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,
|
2026-10-09 23:13:43 +00:00
|
|
|
|
rule=rule["id"] if rule else None,
|
|
|
|
|
|
verified=detail["verified"], retried=detail["retried"])
|
2026-10-07 00:49:23 +00:00
|
|
|
|
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",
|
2026-10-09 23:13:43 +00:00
|
|
|
|
"rule": rule["id"] if rule else None,
|
|
|
|
|
|
"verified": detail["verified"],
|
|
|
|
|
|
"retried": detail["retried"]})
|
2026-10-07 00:49:23 +00:00
|
|
|
|
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"
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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"
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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"
|
2026-10-07 01:50:18 +00:00
|
|
|
|
if _peer_suppressed(state, match["sig"], now, log):
|
|
|
|
|
|
return "duplicate-suppressed"
|
2026-10-09 23:13:43 +00:00
|
|
|
|
ok, detail = send_answer(socket_path, pane_id, key,
|
|
|
|
|
|
enter=match.get("enter", True),
|
|
|
|
|
|
kind=match["kind"], sig=match["sig"])
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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,
|
2026-10-09 23:13:43 +00:00
|
|
|
|
options=match["options"], cue=match["cue"],
|
|
|
|
|
|
verified=detail["verified"], retried=detail["retried"])
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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"],
|
2026-10-07 00:49:23 +00:00
|
|
|
|
"cue": match["cue"], "via": via,
|
2026-10-09 23:13:43 +00:00
|
|
|
|
"rule": rule["id"] if rule else None,
|
|
|
|
|
|
"verified": detail["verified"],
|
|
|
|
|
|
"retried": detail["retried"]})
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
def _log_posture(socket_path, pane_id, log, saved_default=None):
|
2026-10-07 01:50:18 +00:00
|
|
|
|
"""Log the pane's permission posture once at watcher start.
|
|
|
|
|
|
|
|
|
|
|
|
The watcher answers with per-choice logging in every mode (that
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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,
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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
|
2026-10-09 23:13:43 +00:00
|
|
|
|
try:
|
|
|
|
|
|
effective = effective_posture(posture, saved_default)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
effective = {"effective_mode": posture["mode"],
|
|
|
|
|
|
"effective_bypass": posture["bypass"]}
|
2026-10-07 01:50:18 +00:00
|
|
|
|
try:
|
|
|
|
|
|
log.log("info", "pane posture", mode=posture["mode"],
|
2026-10-09 23:13:43 +00:00
|
|
|
|
bypass=posture["bypass"], flags=posture["flags"],
|
|
|
|
|
|
effective_mode=effective["effective_mode"],
|
|
|
|
|
|
effective_bypass=effective["effective_bypass"])
|
2026-10-07 01:50:18 +00:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
2026-10-09 23:13:43 +00:00
|
|
|
|
if (effective["effective_bypass"]
|
|
|
|
|
|
or effective["effective_mode"] not in ("default", None)):
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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"],
|
2026-10-09 23:13:43 +00:00
|
|
|
|
"flags": posture["flags"],
|
|
|
|
|
|
"effective_mode": effective["effective_mode"],
|
|
|
|
|
|
"effective_bypass":
|
|
|
|
|
|
effective["effective_bypass"]})
|
2026-10-07 01:50:18 +00:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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))
|
2026-10-07 01:50:18 +00:00
|
|
|
|
state = WatcherState(
|
2026-10-09 23:13:43 +00:00
|
|
|
|
persist_path=answered_file_for(socket_path, pane_id),
|
|
|
|
|
|
hourly_cap=hourly_cap_for(socket_path))
|
2026-10-07 00:25:46 +00:00
|
|
|
|
log.log("info", "watcher started", socket=socket_path, pane=pane_id,
|
|
|
|
|
|
dry_run=dry_run, pid=os.getpid())
|
2026-10-09 23:13:43 +00:00
|
|
|
|
_log_posture(socket_path, pane_id, log,
|
|
|
|
|
|
saved_default=saved_default_profile())
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 01:50:18 +00:00
|
|
|
|
_PIDFILE_LOCK_FH = None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
def _claim_pidfile(pidfile):
|
2026-10-07 01:50:18 +00:00
|
|
|
|
"""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
|
2026-10-07 00:25:46 +00:00
|
|
|
|
me = os.getpid()
|
|
|
|
|
|
try:
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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
|
2026-10-07 00:25:46 +00:00
|
|
|
|
except Exception:
|
|
|
|
|
|
other = None
|
|
|
|
|
|
if other and other != me and _pid_alive(other) and _pid_is_watcher(other):
|
2026-10-07 01:50:18 +00:00
|
|
|
|
# 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()
|
2026-10-07 00:25:46 +00:00
|
|
|
|
return False
|
|
|
|
|
|
try:
|
2026-10-07 01:50:18 +00:00
|
|
|
|
fh.seek(0)
|
|
|
|
|
|
fh.truncate()
|
|
|
|
|
|
fh.write(str(me))
|
|
|
|
|
|
fh.flush()
|
2026-10-07 00:25:46 +00:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-10-07 01:50:18 +00:00
|
|
|
|
if _PIDFILE_LOCK_FH is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
_PIDFILE_LOCK_FH.close()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
_PIDFILE_LOCK_FH = fh
|
2026-10-07 00:25:46 +00:00
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 01:50:18 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
def start_watcher(socket_path, pane_id, dry_run=False):
|
2026-10-07 01:50:18 +00:00
|
|
|
|
pid = watcher_alive(socket_path, pane_id)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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):
|
2026-10-07 01:50:18 +00:00
|
|
|
|
pid = watcher_alive(socket_path, pane_id)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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.
|
|
|
|
|
|
"""
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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)
|
2026-10-09 23:13:43 +00:00
|
|
|
|
if len(parts) == 2 and any(h in parts[1]
|
|
|
|
|
|
for h in AGENT_PANE_HINTS):
|
2026-10-07 00:25:46 +00:00
|
|
|
|
out.append(parts[0])
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 23:13:43 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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)
|
2026-10-07 01:50:18 +00:00
|
|
|
|
return watcher_alive(socket_path, pane_id) is not None
|
2026-10-07 00:25:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-09 23:13:43 +00:00
|
|
|
|
panes = agent_panes(sock)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
if not panes:
|
|
|
|
|
|
results.append({"socket": sock, "status": "no_muse_panes"})
|
|
|
|
|
|
for pane in panes:
|
2026-10-07 01:50:18 +00:00
|
|
|
|
if watcher_alive(sock, pane):
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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
|
2026-10-09 23:13:43 +00:00
|
|
|
|
for pane in agent_panes(sock):
|
2026-10-07 01:50:18 +00:00
|
|
|
|
if watcher_alive(sock, pane):
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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":
|
2026-10-07 01:50:18 +00:00
|
|
|
|
existing = watcher_alive(args.socket, args.pane)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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)
|
2026-10-07 01:50:18 +00:00
|
|
|
|
running = watcher_alive(args.socket, args.pane)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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:
|
2026-10-07 01:50:18 +00:00
|
|
|
|
_release_pidfile(pidfile)
|
2026-10-07 00:25:46 +00:00
|
|
|
|
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())
|