feat(muse): harden choice watcher concurrency, add rules dictionary, resume pool, and session bind

This commit is contained in:
operator
2026-10-07 01:50:18 +00:00
parent 90f4ef661a
commit 094bd7d691
10 changed files with 2166 additions and 40 deletions
+353 -38
View File
@@ -28,6 +28,7 @@ Usage:
"""
import argparse
import fcntl
import hashlib
import json
import os
@@ -53,6 +54,7 @@ POLL_INTERVAL = 0.5
STABILITY_POLLS = 2
MAX_ANSWERS_PER_HOUR = 20
ANSWERED_TTL_SECONDS = 600 # identical prompt back after 10m => stuck, allow one recovery answer
CLAIM_TTL_SECONDS = 30 # concurrent-claim window: bounds wedge if winner dies pre-send
RULES_FILE = os.path.join(REPO_ROOT, "muse-choices-rules.json")
HOLD_WINDOW_SECONDS = 120 # D3: short hold window, then expire to approve
NEGATIVE_KEYS = {"muse-approval": "2", "yn": "n"} # D2 deny keys
@@ -163,6 +165,13 @@ def logfile_for(socket_path, pane_id):
)
def answered_file_for(socket_path, pane_id):
"""On-disk answered-sig store: restarts must not re-answer prompts."""
return os.path.join(
STATE_DIR, "%s-%s-%s.answered.json" % (FILE_PREFIX, slug_socket(socket_path), clean_pane(pane_id))
)
class WatcherLog:
"""Never-raising JSON-lines logger with best-effort rotation."""
@@ -624,21 +633,49 @@ def _child_pids(pid):
def muse_approval_flags(cmd_argv):
"""Approval posture of a muse argv: {"auto_approve", "flags"}."""
"""Approval posture of a muse argv.
Returns {"auto_approve", "flags", "profile", "mode", "bypass"}.
profile is the --permission-profile id (built-ins :read-only,
:standard, :unrestricted) or None; mode is "yolo" for --yolo, the
profile id when one was passed, else "default"; bypass is True
when tool-approval prompts are disabled at launch (yolo,
--disable-approval, or --approval-mode=never), in which case the
pane shows no approval dialogs (questions may still render, so the
watcher stays active).
"""
flags = []
argv = cmd_argv or []
if "--yolo" in argv:
flags.append("yolo")
if "--disable-approval" in argv:
flags.append("disable-approval")
if "--disable-sandbox" in argv:
flags.append("disable-sandbox")
if "--trust-workspace" in argv:
flags.append("trust-workspace")
profile = None
for i, arg in enumerate(argv):
if arg == "--approval-mode" and i + 1 < len(argv):
flags.append("approval-mode=%s" % argv[i + 1])
elif arg.startswith("--approval-mode="):
flags.append("approval-mode=%s" % arg.split("=", 1)[1])
elif arg == "--permission-profile" and i + 1 < len(argv):
profile = argv[i + 1]
flags.append("permission-profile=%s" % profile)
elif arg.startswith("--permission-profile="):
profile = arg.split("=", 1)[1]
flags.append("permission-profile=%s" % profile)
auto = ("yolo" in flags or "disable-approval" in flags
or "approval-mode=never" in flags)
return {"auto_approve": auto, "flags": flags}
if "yolo" in flags:
mode = "yolo"
elif profile is not None:
mode = profile
else:
mode = "default"
return {"auto_approve": auto, "flags": flags, "profile": profile,
"mode": mode, "bypass": auto}
def launch_opt_out(cmd_argv):
@@ -688,9 +725,11 @@ def pane_muse_argv(socket_path, pane_id):
# Minimum pane geometry for reliable approval rendering. Empirically
# derived: a 35x7 tile drops approval text the matcher needs, while
# 35x35/36x35/71x27 panes answer cleanly. Below either bound the pane
# is flagged squeezed (see `box runtime layout` / `spread`).
MIN_APPROVAL_WIDTH = 40
# 35x35/36x35/71x27 panes answer cleanly (width 35 works when tall
# enough; capture uses -J so wrapping is width-independent). Below
# either bound the pane is flagged squeezed (see `box runtime layout` /
# `spread`).
MIN_APPROVAL_WIDTH = 35
MIN_APPROVAL_HEIGHT = 12
# Session naming convention (see NODES.md): <node>--<role>--<id>
@@ -749,7 +788,8 @@ def runtime_rows(socket_path):
or pane_height < MIN_APPROVAL_HEIGHT))
node = node_from_session(session)
is_muse = "muse-bin" in cmd or "muse-code" in cmd
posture = {"auto_approve": None, "flags": []}
posture = {"auto_approve": None, "flags": [], "profile": None,
"mode": None, "bypass": None}
if is_muse and pane_pid:
candidates = [pane_pid] + _child_pids(pane_pid)
for cand in candidates:
@@ -763,7 +803,7 @@ def runtime_rows(socket_path):
continue
st = runtime_state(text)
match = st["match"] or {}
watcher_pid = is_running(socket_path, pane_id)
watcher_pid = watcher_alive(socket_path, pane_id)
rows.append({
"socket": socket_path, "session": session,
"window": window, "pane": pane_id, "cmd": cmd,
@@ -773,6 +813,9 @@ def runtime_rows(socket_path):
"squeezed": squeezed,
"auto_approve": posture["auto_approve"],
"approval_flags": posture["flags"],
"permission_mode": posture["mode"],
"permission_profile": posture["profile"],
"permission_bypass": posture["bypass"],
"state": st["state"],
"prompt_kind": match.get("kind"),
"prompt_key": match.get("key"),
@@ -813,14 +856,124 @@ def pane_state(socket_path, pane_id):
class WatcherState:
"""Tracks prompt stability and once-per-prompt answering."""
"""Tracks prompt stability and once-per-prompt answering.
def __init__(self):
When persist_path is set, answered sigs survive restarts (atomic
JSON store): a daemon that restarts while an answered prompt is
still visible must not answer it again (observed live: same sig
re-answered minutes later, stray "1" landing in the input box).
"""
def __init__(self, persist_path=None):
self.pending_sig = None
self.stable_count = 0
self.answered_sigs = {}
self.answer_times = []
self.last_capped_sig = None
self._persist_path = persist_path
if persist_path:
self._load_answered()
def _load_answered(self):
try:
with open(self._persist_path) as f:
data = json.load(f)
except Exception:
return
if not isinstance(data, dict):
return
now = time.time()
for sig, ts in data.items():
if (isinstance(sig, str) and isinstance(ts, (int, float))
and now - ts < ANSWERED_TTL_SECONDS):
self.answered_sigs[sig] = ts
def _save_answered(self):
if not self._persist_path:
return
try:
tmp = "%s.tmp.%d" % (self._persist_path, os.getpid())
with open(tmp, "w") as f:
json.dump(self.answered_sigs, f)
os.replace(tmp, self._persist_path)
except Exception:
pass
def refresh_answered(self):
"""Merge on-disk answered sigs into memory. Never raises.
Cross-process once-per-prompt: a peer watcher that answered
after our startup persisted its sig; re-reading before we type
suppresses the duplicate ('11' in the input box).
"""
if not self._persist_path:
return
try:
with open(self._persist_path) as f:
data = json.load(f)
except Exception:
return
if not isinstance(data, dict):
return
now = time.time()
for sig, ts in data.items():
if (isinstance(sig, str) and isinstance(ts, (int, float))
and now - ts < ANSWERED_TTL_SECONDS):
if sig not in self.answered_sigs:
self.answered_sigs[sig] = ts
def try_claim(self, sig, now=None):
"""Atomically claim a sig for this process. True iff we won.
O_CREAT|O_EXCL makes the first claimer win even when two
watchers reach the same stable prompt in the same poll window;
the loser suppresses instead of double-typing. Claims expire
after CLAIM_TTL_SECONDS (concurrent window only; stuck-dialog
recovery is governed by the answered store's longer TTL), so a
winner that dies between claim and send wedges at most briefly.
Memory-only states (no persist path) always win. Never raises.
"""
if not self._persist_path:
return True
now = time.time() if now is None else now
base = os.path.basename(self._persist_path)
directory = os.path.dirname(self._persist_path) or STATE_DIR
# Prune expired claims for this pane (best-effort).
try:
for name in os.listdir(directory):
if not name.startswith(base + ".") or not name.endswith(".claim"):
continue
p = os.path.join(directory, name)
try:
with open(p) as f:
rec = json.load(f)
ts = float(rec.get("ts", 0))
except Exception:
ts = 0
try:
if now - ts >= CLAIM_TTL_SECONDS:
os.remove(p)
except OSError:
pass
except Exception:
pass
path = "%s.%s.claim" % (self._persist_path, sig)
try:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
return False
except OSError:
return True # claim store unavailable: fail open, send once
try:
os.write(fd, json.dumps({"pid": os.getpid(),
"ts": now}).encode())
except Exception:
pass
try:
os.close(fd)
except Exception:
pass
return True
def _prune_times(self, now):
cutoff = now - 3600
@@ -869,6 +1022,7 @@ class WatcherState:
self.answer_times.append(now)
self.pending_sig = None
self.stable_count = 0
self._save_answered()
def _tmux(socket_path, *args, timeout=5):
@@ -887,9 +1041,15 @@ def pane_exists(socket_path, pane_id):
def capture_pane(socket_path, pane_id, history=80):
"""Capture pane text with wrapped rows joined (-J).
-J makes matching width-independent: narrow panes wrap the same
dialog onto more physical rows, which otherwise pushes cue/option
spans apart and breaks the matcher.
"""
try:
r = _tmux(socket_path, "capture-pane", "-p", "-t", pane_id, "-S", "-%d" % history,
timeout=5)
r = _tmux(socket_path, "capture-pane", "-p", "-J", "-t", pane_id,
"-S", "-%d" % history, timeout=5)
if r.returncode != 0:
return None
return r.stdout
@@ -1065,10 +1225,53 @@ def list_holds():
return out
def _peer_suppressed(state, sig, now, log):
"""True when a peer watcher already owns sig; suppress our send.
Checks the shared on-disk answered store first (peer answered
earlier and persisted), then attempts an atomic claim (peer racing
us in the same window). On suppression the pending prompt is
reset so later polls re-observe cleanly; answered-store hits are
already merged into memory, claim-loss is not recorded (peer's
send owns it, and its claim expires quickly if it dies). Never
raises; memory-only states never suppress.
"""
try:
state.refresh_answered()
except Exception:
pass
try:
ts = state.answered_sigs.get(sig)
if ts is not None and now - ts < ANSWERED_TTL_SECONDS:
try:
log.log("info", "duplicate suppressed (peer answered)",
sig=sig)
except Exception:
pass
state.pending_sig = None
state.stable_count = 0
return True
except Exception:
pass
try:
if not state.try_claim(sig, now):
try:
log.log("info", "duplicate suppressed (peer claimed)",
sig=sig)
except Exception:
pass
state.pending_sig = None
state.stable_count = 0
return True
except Exception:
pass
return False
def _check_hold(socket_path, pane_id, state, match, now, log):
"""Rule-hold gate. Returns None (fresh: evaluate rules), "released"
(hold over: approve WITHOUT re-evaluating, else expiry would
re-hold forever), "held", or "denied".
re-hold forever), "held", "denied", or "duplicate-suppressed".
The holdfile is the single source of truth (crash-safe,
box-visible): present + fresh => suppress; directive deny => deny
@@ -1093,6 +1296,8 @@ def _check_hold(socket_path, pane_id, state, match, now, log):
log.log("warn", "resolve-deny refused: D2 never denies questions",
sig=sig, kind=match["kind"])
return "held"
if _peer_suppressed(state, sig, now, log):
return "duplicate-suppressed"
ok = send_answer(socket_path, pane_id, neg, enter=True)
clear_hold(socket_path, pane_id)
state.record_answer(sig, now)
@@ -1132,8 +1337,8 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
now = time.time()
gate = _check_hold(socket_path, pane_id, state, match, now, log) \
if match is not None else None
if gate in ("held", "denied"):
if gate == "denied":
if gate in ("held", "denied", "duplicate-suppressed"):
if gate in ("denied", "duplicate-suppressed"):
state.pending_sig = None
state.stable_count = 0
return gate
@@ -1175,6 +1380,8 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
rule=rule["id"] if rule else None)
state.record_answer(match["sig"], now)
return "dry-denied"
if _peer_suppressed(state, match["sig"], now, log):
return "duplicate-suppressed"
ok = send_answer(socket_path, pane_id, neg, enter=True)
state.record_answer(match["sig"], now)
log.log("info" if ok else "error", "denied %s" % neg,
@@ -1216,6 +1423,8 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
options=match["options"], cue=match["cue"])
state.record_answer(match["sig"], now)
return "dry-answered"
if _peer_suppressed(state, match["sig"], now, log):
return "duplicate-suppressed"
ok = send_answer(socket_path, pane_id, key,
enter=match.get("enter", True))
state.record_answer(match["sig"], now)
@@ -1242,12 +1451,45 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
return "waiting"
def _log_posture(socket_path, pane_id, log):
"""Log the pane's permission posture once at watcher start.
The watcher answers with per-choice logging in every mode (that
trail is the default path and informs policy); a bypass posture
(yolo / approval disabled) additionally gets a box audit record,
since the session then makes choices outside the trail. Never
raises.
"""
try:
posture = muse_approval_flags(pane_muse_argv(socket_path, pane_id))
except Exception:
return
try:
log.log("info", "pane posture", mode=posture["mode"],
bypass=posture["bypass"], flags=posture["flags"])
except Exception:
pass
try:
if posture["bypass"] or posture["mode"] not in ("default", None):
audit("muse-choice-posture",
name="%s:%s" % (os.path.basename(socket_path), pane_id),
extra={"socket": socket_path, "pane": pane_id,
"mode": posture["mode"],
"profile": posture["profile"],
"bypass": posture["bypass"],
"flags": posture["flags"]})
except Exception:
pass
def watch_loop(socket_path, pane_id, dry_run=False):
"""Main daemon loop. Returns only when the pane is gone or signalled."""
log = WatcherLog(logfile_for(socket_path, pane_id))
state = WatcherState()
state = WatcherState(
persist_path=answered_file_for(socket_path, pane_id))
log.log("info", "watcher started", socket=socket_path, pane=pane_id,
dry_run=dry_run, pid=os.getpid())
_log_posture(socket_path, pane_id, log)
polls = 0
answers = 0
last_heartbeat = time.time()
@@ -1346,6 +1588,30 @@ def is_running(socket_path, pane_id):
return None
def watcher_alive(socket_path, pane_id):
"""Pid of the live watcher for a pane, pidfile or orphan.
is_running covers the normal pidfile case; _watch_procs catches an
identical watcher alive with a lost pidfile (/tmp cleaned under
it). Starters must consult this (not is_running alone) or they
spawn a second daemon onto the same pane (double answers). Never
raises; None means no live watcher.
"""
try:
pid = is_running(socket_path, pane_id)
if pid:
return pid
except Exception:
pass
try:
for proc in _watch_procs():
if proc["socket"] == socket_path and proc["pane"] == pane_id:
return proc["pid"]
except Exception:
pass
return None
def _daemonize():
"""Double-fork away from the controlling terminal (survives shell exit)."""
if os.fork() != 0:
@@ -1361,27 +1627,85 @@ def _daemonize():
os.close(devnull)
_PIDFILE_LOCK_FH = None
def _claim_pidfile(pidfile):
"""Claim a pidfile for this process. Returns False if another live
watcher owns it (concurrent box + timer starts must not pile up)."""
"""Claim a pidfile for this process. Returns False if another
starter holds it. Kernel-enforced: the winner holds an exclusive
flock for its whole lifetime, so concurrent box + timer starts can
never pile two daemons onto one pane (double answers, '11' in the
input box). Call _release_pidfile() on exit."""
global _PIDFILE_LOCK_FH
me = os.getpid()
try:
with open(pidfile) as f:
other = int(f.read().strip())
fh = open(pidfile, "a+")
except OSError:
return False
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, IOError):
fh.close()
return False
try:
fh.seek(0)
content = fh.read().strip()
other = int(content) if content else None
except Exception:
other = None
if other and other != me and _pid_alive(other) and _pid_is_watcher(other):
# Live foreign owner (e.g. a daemon from before locking existed,
# or a pid recycled into a watcher): yield to it.
try:
fcntl.flock(fh, fcntl.LOCK_UN)
except Exception:
pass
fh.close()
return False
try:
with open(pidfile, "w") as f:
f.write(str(me))
fh.seek(0)
fh.truncate()
fh.write(str(me))
fh.flush()
except Exception:
pass
if _PIDFILE_LOCK_FH is not None:
try:
_PIDFILE_LOCK_FH.close()
except Exception:
pass
_PIDFILE_LOCK_FH = fh
return True
def _release_pidfile(pidfile):
"""Drop the claim taken by _claim_pidfile: remove the pidfile only
if we still own it, then release the lock. Never raises."""
global _PIDFILE_LOCK_FH
try:
with open(pidfile) as f:
owner = f.read().strip()
except Exception:
owner = ""
if owner == str(os.getpid()):
try:
os.remove(pidfile)
except OSError:
pass
if _PIDFILE_LOCK_FH is not None:
try:
fcntl.flock(_PIDFILE_LOCK_FH, fcntl.LOCK_UN)
except Exception:
pass
try:
_PIDFILE_LOCK_FH.close()
except Exception:
pass
_PIDFILE_LOCK_FH = None
def start_watcher(socket_path, pane_id, dry_run=False):
pid = is_running(socket_path, pane_id)
pid = watcher_alive(socket_path, pane_id)
if pid:
return {"ok": False, "status": "already_running", "pid": pid}
if not pane_exists(socket_path, pane_id):
@@ -1400,7 +1724,7 @@ def start_watcher(socket_path, pane_id, dry_run=False):
def stop_watcher(socket_path, pane_id, timeout=5):
pid = is_running(socket_path, pane_id)
pid = watcher_alive(socket_path, pane_id)
if not pid:
return {"ok": True, "status": "not_running"}
try:
@@ -1448,7 +1772,7 @@ def _start_detached(socket_path, pane_id, dry_run=False):
os._exit(0)
os.waitpid(pid, 0)
time.sleep(0.2)
return is_running(socket_path, pane_id) is not None
return watcher_alive(socket_path, pane_id) is not None
def start_all(dry_run=False, sockets=None):
@@ -1461,7 +1785,7 @@ def start_all(dry_run=False, sockets=None):
if not panes:
results.append({"socket": sock, "status": "no_muse_panes"})
for pane in panes:
if is_running(sock, pane):
if watcher_alive(sock, pane):
results.append({"socket": sock, "pane": pane,
"status": "already_running"})
continue
@@ -1524,7 +1848,7 @@ def reconcile(sockets=None):
if not os.path.exists(sock):
continue
for pane in muse_panes(sock):
if is_running(sock, pane):
if watcher_alive(sock, pane):
already.append("%s:%s" % (sock, pane))
continue
if _start_detached(sock, pane, dry_run=desired["dry_run"]):
@@ -1660,7 +1984,7 @@ def main(argv=None):
args = ap.parse_args(argv)
if args.cmd == "start":
existing = is_running(args.socket, args.pane)
existing = watcher_alive(args.socket, args.pane)
if existing:
print(json.dumps({"ok": True, "status": "already_running",
"pid": existing,
@@ -1674,7 +1998,7 @@ def main(argv=None):
os._exit(0)
_, status = os.waitpid(pid, 0)
time.sleep(0.3)
running = is_running(args.socket, args.pane)
running = watcher_alive(args.socket, args.pane)
print(json.dumps({"ok": running is not None, "pid": running,
"log": logfile_for(args.socket, args.pane)}))
return 0 if running else 1
@@ -1716,16 +2040,7 @@ def main(argv=None):
try:
return watch_loop(args.socket, args.pane, dry_run=args.dry_run)
finally:
try:
with open(pidfile) as f:
owner = f.read().strip()
except Exception:
owner = ""
if owner == str(os.getpid()):
try:
os.remove(pidfile)
except OSError:
pass
_release_pidfile(pidfile)
if args.cmd == "match":
text = sys.stdin.read()
print(json.dumps(find_choice_prompt(text, tail_window=args.tail_window), indent=1))
+545
View File
@@ -0,0 +1,545 @@
#!/usr/bin/env python3
"""muse_resume_pool: per-repo, profile-aware Muse Code resume pool.
Why this exists
---------------
``muse resume`` scopes its picker by workspace but is blind to muse-auth
profiles, and the TUI ``/resume`` reads session logs itself and lumps every
workspace into one heap. A session resumed under a different credential
than the one that created it fails server-side: the continuation is
cryptographically bound to the creating account, so the server rejects the
resume. This tool lists only the sessions that can actually resume here
and now, and guards ``resume`` calls before they fail.
Data sources (read-only, no secrets)
------------------------------------
- ``~/.local/share/muse/session-index.db`` ``sessions`` table (``mode=ro``).
Fallback when the index is missing: scan ``sessions/*/*/*/*/session.jsonl``
for ``runtime.session.metadata`` (workspace_root) and
``session.name.changed`` (session_name) records, mirroring ``/resume``.
- ``~/.config/muse/active_profile``, ``session_profiles.json``,
``switch_history.jsonl``: profile *names* only. ``auth.json`` token bytes
are never read, logged, or compared.
Profile resolution mirrors ``muse-auth``: cached ``session_profiles.json``
mapping wins; otherwise the latest switch at or before session start; else
the earliest switch; else the active profile as fallback. When the auth
dir is unreadable (e.g. inside the Muse sandbox, which masks it), the
profile is ``unknown`` and only proven mismatches are hidden/blocked.
"""
import argparse
import glob
import json
import os
import sqlite3
import subprocess
import sys
INDEX_COLUMNS = (
"session_id",
"session_name",
"workspace_root",
"workspace_key",
"provider_id",
"model_id",
"git_branch",
"title",
"first_user_prompt",
"created_at_us",
"updated_at_us",
"prompt_count",
"status",
)
def default_paths():
home = os.path.expanduser("~")
data_home = os.environ.get("XDG_DATA_HOME", os.path.join(home, ".local", "share"))
return {
"index_db": os.path.join(data_home, "muse", "session-index.db"),
"sessions_dir": os.path.join(data_home, "muse", "sessions"),
"config_dir": os.path.expanduser("~/.config/muse"),
}
def canonical_workspace(cwd=None):
"""Repo root for the pool: git top-level, else real cwd."""
cwd = cwd or os.getcwd()
try:
out = subprocess.run(
["git", "-C", cwd, "rev-parse", "--show-toplevel"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=10,
)
if out.returncode == 0 and out.stdout.strip():
return os.path.realpath(out.stdout.strip())
except Exception:
pass
return os.path.realpath(cwd)
def load_index_rows(index_db):
"""Read session rows from the index (read-only). None if unavailable."""
if not os.path.exists(index_db):
return None
cols = ", ".join(INDEX_COLUMNS)
try:
uri = "file:{}?mode=ro".format(index_db.replace("?", "%3F"))
conn = sqlite3.connect(uri, uri=True, timeout=5)
try:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT {} FROM sessions ORDER BY "
"updated_at_us DESC, created_at_us DESC, session_id ASC".format(cols)
)
return [dict(r) for r in cur.fetchall()]
finally:
conn.close()
except sqlite3.Error:
return None
def _scan_log_for_session(session_log):
"""Extract workspace/name/title from one session.jsonl (bounded read)."""
workspace = None
name = None
title = None
first_prompt = None
created_at_us = None
updated_at_us = None
prompt_count = 0
try:
with open(session_log, "r", errors="replace") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue
if "children" in rec: # retained permission frame wrapper
continue
rec_at = rec.get("recorded_at")
if isinstance(rec_at, int):
if created_at_us is None:
created_at_us = rec_at
updated_at_us = rec_at
ptype = rec.get("payload_type", "")
payload = rec.get("payload", {}) if isinstance(rec.get("payload"), dict) else {}
if ptype == "runtime.session.metadata":
record = payload.get("record", {})
workspace = workspace or record.get("workspace_root")
elif ptype == "session.name.changed":
if payload.get("new_name"):
name = payload["new_name"]
elif ptype == "runtime.session":
event = payload.get("event", {})
if event.get("kind") == "started" and not first_prompt:
prompt = event.get("prompt") or ""
first_prompt = prompt[:200]
title = title or prompt[:80]
prompt_count += 1
except OSError:
return None
if workspace is None and name is None and created_at_us is None:
return None
session_id = os.path.basename(os.path.dirname(session_log))
return {
"session_id": session_id,
"session_name": name,
"workspace_root": workspace,
"workspace_key": workspace,
"provider_id": None,
"model_id": None,
"git_branch": None,
"title": title or "New session",
"first_user_prompt": first_prompt,
"created_at_us": created_at_us,
"updated_at_us": updated_at_us or created_at_us,
"prompt_count": prompt_count,
"status": "valid",
}
def scan_session_logs(sessions_dir):
"""Fallback pool source: scan top-level session.jsonl files directly."""
pattern = os.path.join(sessions_dir, "*", "*", "*", "*", "session.jsonl")
rows = []
for path in glob.glob(pattern):
row = _scan_log_for_session(path)
if row:
rows.append(row)
rows.sort(
key=lambda r: (
r.get("updated_at_us") or 0,
r.get("created_at_us") or 0,
r.get("session_id") or "",
),
reverse=True,
)
return rows
def load_auth_state(config_dir):
"""Load profile names only. Never touches auth.json token bytes."""
state = {
"readable": False,
"active": None,
"session_profiles": {},
"switch_history": [],
}
if not os.path.isdir(config_dir):
return state
if not (os.access(config_dir, os.R_OK) and os.access(config_dir, os.X_OK)):
return state
state["readable"] = True
try:
with open(os.path.join(config_dir, "active_profile"), "r") as fh:
state["active"] = fh.read().strip() or None
except OSError:
pass
try:
with open(os.path.join(config_dir, "session_profiles.json"), "r") as fh:
data = json.load(fh)
if isinstance(data, dict):
state["session_profiles"] = {
str(k): str(v) for k, v in data.items()
}
except (OSError, ValueError):
pass
try:
history = []
with open(os.path.join(config_dir, "switch_history.jsonl"), "r") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except ValueError:
continue
if entry.get("profile"):
history.append(entry)
history.sort(key=lambda e: e.get("epoch", 0))
state["switch_history"] = history
except OSError:
pass
return state
def resolve_profile(session_id, created_at_us, auth_state):
"""Return (profile_or_None, source). Mirrors muse-auth resolution order."""
if not auth_state.get("readable"):
return None, "unknown"
cached = auth_state.get("session_profiles", {})
if session_id in cached:
return cached[session_id], "cached"
history = auth_state.get("switch_history", [])
start_epoch = (created_at_us / 1e6) if created_at_us else None
if history and start_epoch:
for switch in reversed(history):
if switch.get("epoch", 0) <= start_epoch:
return switch.get("profile"), "history"
return history[0].get("profile"), "history"
if auth_state.get("active"):
return auth_state["active"], "fallback"
return None, "unknown"
def annotate_rows(rows, auth_state):
"""Attach profile + source to each row (mutates and returns rows)."""
for row in rows:
profile, source = resolve_profile(
row.get("session_id"), row.get("created_at_us"), auth_state
)
row["auth_profile"] = profile
row["auth_source"] = source
return rows
def pool_for_workspace(rows, workspace):
"""Exact workspace_key match (workspace_root fallback), index order kept."""
pool = []
for row in rows:
key = row.get("workspace_key") or row.get("workspace_root")
if key and os.path.realpath(key) == workspace:
pool.append(row)
return pool
def split_resumable(pool, active_profile):
"""(resumable, blocked): only proven profile mismatches are blocked.
Unknown profiles (sandboxed auth dir, no mapping/history) stay resumable
but flagged, since blocking them would hide possibly valid sessions.
"""
resumable, blocked = [], []
for row in pool:
profile = row.get("auth_profile")
if active_profile and profile and profile != active_profile:
blocked.append(row)
else:
resumable.append(row)
return resumable, blocked
def resolve_ref(rows, ref):
"""Resolve UUID / UUID prefix / session name. Returns (matches, kind)."""
ref = (ref or "").strip()
if not ref:
return [], "empty"
exact = [r for r in rows if r.get("session_id") == ref]
if exact:
return exact, "uuid"
named = [r for r in rows if (r.get("session_name") or "") == ref]
if named:
return named, "name"
if len(ref) >= 8:
prefixed = [
r for r in rows if (r.get("session_id") or "").startswith(ref)
]
if prefixed:
return prefixed, "prefix"
return [], "none"
def check_resume(rows, ref, workspace, auth_state):
"""Guard decision for resuming ``ref`` from ``workspace``.
Returns dict(ok=bool, reason=str, detail=str, fix=str, row=row|None).
"""
matches, kind = resolve_ref(rows, ref)
if kind == "empty" or not matches:
return {
"ok": False,
"reason": "unknown-session",
"detail": "No session matches '{}'.".format(ref),
"fix": "List this repo's pool: muse_resume_pool.py pool",
"row": None,
}
if len(matches) > 1:
ids = ", ".join(m["session_id"][:12] for m in matches[:5])
return {
"ok": False,
"reason": "ambiguous",
"detail": "'{}' matches {} sessions: {}".format(ref, len(matches), ids),
"fix": "Use a longer UUID prefix or the full session id.",
"row": None,
}
row = matches[0]
key = row.get("workspace_key") or row.get("workspace_root")
if not key or os.path.realpath(key) != workspace:
return {
"ok": False,
"reason": "wrong-workspace",
"detail": "Session '{}' belongs to workspace '{}', not '{}'.".format(
row.get("session_name") or row["session_id"][:12], key, workspace
),
"fix": "cd '{}' first, or pick a session from this repo's pool.".format(key or "?"),
"row": row,
}
if row.get("status") and row["status"] != "valid":
return {
"ok": False,
"reason": "bad-status",
"detail": "Session '{}' has status '{}'.".format(
row.get("session_name") or row["session_id"][:12], row["status"]
),
"fix": "Pick a session with status 'valid' from this repo's pool.",
"row": row,
}
active = auth_state.get("active")
profile = row.get("auth_profile")
if active and profile and profile != active:
return {
"ok": False,
"reason": "wrong-profile",
"detail": "Session '{}' was created under muse-auth profile '{}' "
"but the active profile is '{}'; the server would reject the "
"resume (continuation is bound to the creating credential).".format(
row.get("session_name") or row["session_id"][:12], profile, active
),
"fix": "Run `muse-auth use {}` (outside the sandbox), then resume.".format(profile),
"row": row,
}
detail = "Session '{}' is resumable here.".format(
row.get("session_name") or row["session_id"][:12]
)
if not auth_state.get("readable"):
detail += " (Profile unverified: auth dir unreadable from this shell.)"
elif not profile:
detail += " (Profile unknown: no mapping or switch history.)"
return {
"ok": True,
"reason": "ok",
"detail": detail,
"fix": "",
"row": row,
}
def load_rows(paths):
"""Index first, log-scan fallback. Returns (rows, source)."""
rows = load_index_rows(paths["index_db"])
if rows is not None:
return rows, "index"
return scan_session_logs(paths["sessions_dir"]), "log-scan"
def format_pool_table(resumable, blocked, show_all):
lines = []
header = "{:<18} {:<12} {:<10} {:<5} {}".format(
"NAME", "SESSION", "PROFILE", "MSGS", "TITLE"
)
lines.append(header)
for row in resumable:
flag = "?" if not row.get("auth_profile") else " "
lines.append(
"{:<18} {:<12} {:<10} {:<5} {}{}".format(
(row.get("session_name") or "-")[:18],
(row.get("session_id") or "")[:12],
(row.get("auth_profile") or "?")[:10],
row.get("prompt_count", 0),
flag,
(row.get("title") or "")[:60],
)
)
if show_all:
for row in blocked:
lines.append(
"{:<18} {:<12} {:<10} {:<5} {} [BLOCKED: profile mismatch]".format(
(row.get("session_name") or "-")[:18],
(row.get("session_id") or "")[:12],
(row.get("auth_profile") or "?")[:10],
row.get("prompt_count", 0),
(row.get("title") or "")[:60],
)
)
elif blocked:
lines.append(
"({} session(s) hidden: wrong muse-auth profile; use --all to show)".format(
len(blocked)
)
)
return "\n".join(lines)
def cmd_pool(args, paths):
workspace = os.path.realpath(args.workspace or canonical_workspace())
rows, source = load_rows(paths)
auth_state = load_auth_state(paths["config_dir"])
annotate_rows(rows, auth_state)
pool = pool_for_workspace(rows, workspace)
resumable, blocked = split_resumable(pool, auth_state.get("active"))
if args.json:
print(json.dumps({
"workspace": workspace,
"source": source,
"active_profile": auth_state.get("active"),
"auth_readable": auth_state.get("readable"),
"resumable": resumable,
"blocked": blocked if args.all else [],
"blocked_count": len(blocked),
}, indent=2, default=str))
return 0
print("workspace: {} (source: {})".format(workspace, source))
if auth_state.get("readable"):
print("active profile: {}".format(auth_state.get("active") or "(none)"))
else:
print("active profile: ? (auth dir unreadable from this shell)")
if not pool:
print("No sessions for this workspace.")
return 0
print(format_pool_table(resumable, blocked, args.all))
return 0
def cmd_check(args, paths):
workspace = os.path.realpath(args.workspace or canonical_workspace())
rows, _ = load_rows(paths)
auth_state = load_auth_state(paths["config_dir"])
annotate_rows(rows, auth_state)
decision = check_resume(rows, args.ref, workspace, auth_state)
if args.json:
row = dict(decision["row"]) if decision["row"] else None
print(json.dumps({
"ok": decision["ok"],
"reason": decision["reason"],
"detail": decision["detail"],
"fix": decision["fix"],
"row": row,
}, indent=2, default=str))
else:
status = "OK" if decision["ok"] else "BLOCKED ({})".format(decision["reason"])
print("{}: {}".format(status, decision["detail"]))
if decision["fix"]:
print("fix: {}".format(decision["fix"]))
return 0 if decision["ok"] else 1
def cmd_resume(args, paths):
workspace = os.path.realpath(args.workspace or canonical_workspace())
rows, _ = load_rows(paths)
auth_state = load_auth_state(paths["config_dir"])
annotate_rows(rows, auth_state)
if args.ref == "--last":
pool = pool_for_workspace(rows, workspace)
resumable, _ = split_resumable(pool, auth_state.get("active"))
if not resumable:
print("No resumable sessions for this workspace.", file=sys.stderr)
return 1
session_id = resumable[0]["session_id"]
else:
decision = check_resume(rows, args.ref, workspace, auth_state)
if not decision["ok"]:
print("refusing to resume: {}".format(decision["detail"]), file=sys.stderr)
if decision["fix"]:
print("fix: {}".format(decision["fix"]), file=sys.stderr)
return 1
session_id = decision["row"]["session_id"]
cmd = ["muse-code", "resume", session_id]
if args.dry_run:
print("would exec: {}".format(" ".join(cmd)))
return 0
os.execvp(cmd[0], cmd)
return 0 # unreachable
def main(argv=None):
parser = argparse.ArgumentParser(
prog="muse_resume_pool",
description="Per-repo, profile-aware Muse Code resume pool and guard.",
)
parser.add_argument(
"--workspace",
help="Workspace root to scope to (default: git top-level or cwd).",
)
sub = parser.add_subparsers(dest="command", required=True)
p_pool = sub.add_parser("pool", help="List sessions resumable here and now.")
p_pool.add_argument("--all", action="store_true",
help="Also show profile-blocked sessions.")
p_pool.add_argument("--json", action="store_true", help="Machine-readable output.")
p_pool.set_defaults(func=cmd_pool)
p_check = sub.add_parser("check", help="Explain whether a resume would succeed.")
p_check.add_argument("ref", help="Session UUID, UUID prefix, or session name.")
p_check.add_argument("--json", action="store_true", help="Machine-readable output.")
p_check.set_defaults(func=cmd_check)
p_resume = sub.add_parser("resume", help="Guard then exec muse-code resume.")
p_resume.add_argument("ref", help="Session UUID, prefix, name, or --last.")
p_resume.add_argument("--dry-run", action="store_true",
help="Print the resume command instead of exec'ing.")
p_resume.set_defaults(func=cmd_resume)
args = parser.parse_args(argv)
return args.func(args, default_paths())
if __name__ == "__main__":
sys.exit(main())
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""muse_session_bind.py — Per-session credential isolation (P3).
Each muse session gets its own config root::
/tmp/muse-session-<pid>/muse/
<everything symlinked from the global config EXCEPT auth.json>
auth.json <- COPY of the bound profile's credentials (0600)
/tmp/muse-session-<pid>/bind.json <- {profile, pid, created, auth_src}
``launch`` execs muse with XDG_CONFIG_HOME pointed at the session dir
(the binary resolves its config root as $XDG_CONFIG_HOME/muse, else
$HOME/.config/muse), so switching profiles never disturbs live
sessions: the fleet-wide 400 outage class disappears by construction.
Exec (not supervise) preserves the pane's ``muse-bin`` identity, so
watcher coverage and ``box runtime`` keep working unchanged.
Token refreshes land in the session copy. ``save``/``reap`` copy newer
bytes back to the profile store (newest-wins across concurrent
sessions sharing a profile; nothing is ever written to the legacy
global auth.json). Dead sessions are reaped by scan, so kill -9 loses
nothing but promptness.
Companion to the peer's muse_resume_pool (which reads
session_profiles.json): ``launch --session-id`` records the binding
there for future resume guards.
"""
import argparse
import hashlib
import json
import os
import shutil
import sys
import time
from datetime import datetime, timezone
SESSION_PREFIX = "muse-session-"
BIND_FILENAME = "bind.json"
AUTH_FILENAME = "auth.json"
def default_config_src():
"""Global config source (explicit env wins, else the real home)."""
return (os.environ.get("MUSE_CONFIG_SRC")
or os.path.join(os.path.expanduser("~"), ".config", "muse"))
def session_dir_for(parent, pid):
return os.path.join(parent, "%s%d" % (SESSION_PREFIX, pid))
def _now():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _pid_alive(pid):
try:
os.kill(pid, 0)
return True
except Exception:
return False
def _pid_is_muse(pid):
"""True if pid's cmdline looks like a muse session (pid-reuse guard)."""
try:
with open("/proc/%d/cmdline" % pid, "rb") as f:
cmd = f.read().decode(errors="replace").lower()
return "muse-bin" in cmd or "muse-code" in cmd
except Exception:
return False
def _fingerprint(path):
"""Short sha256 of a credential file for logs (never the bytes)."""
try:
h = hashlib.sha256()
with open(path, "rb") as f:
h.update(f.read())
return h.hexdigest()[:12]
except OSError:
return "missing"
def _write_private_bytes(path, data):
"""Write bytes with 0600 perms, atomically. Returns True on success."""
try:
tmp = "%s.tmp.%d" % (path, os.getpid())
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, data)
os.fsync(fd)
finally:
os.close(fd)
os.replace(tmp, path)
return True
except OSError:
return False
def profile_auth_path(config_src, profile):
return os.path.join(config_src, "accounts", profile, AUTH_FILENAME)
def read_bind(sessdir):
try:
with open(os.path.join(sessdir, BIND_FILENAME)) as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except (OSError, ValueError):
return None
def session_liveness(sessdir):
"""live | dead | unknown (no/invalid bind record: never reap)."""
bind = read_bind(sessdir)
if not bind or not isinstance(bind.get("pid"), int):
return "unknown"
pid = bind["pid"]
if _pid_alive(pid) and _pid_is_muse(pid):
return "live"
return "dead"
def list_bound(parent="/tmp"):
"""Session dirs carrying our bind record (foreign dirs ignored)."""
out = []
try:
names = sorted(os.listdir(parent))
except OSError:
return out
for name in names:
if not name.startswith(SESSION_PREFIX):
continue
sessdir = os.path.join(parent, name)
if not os.path.isdir(sessdir):
continue
if read_bind(sessdir) is None:
continue
out.append(sessdir)
return out
def build_session_dir(parent, pid, config_src, auth_src, profile):
"""Create the isolated config root. Returns sessdir.
Raises RuntimeError when the slot is held by a live session, or
OSError/ValueError for missing sources.
"""
auth_src = os.path.realpath(auth_src)
if not os.path.isfile(auth_src):
raise ValueError("no credentials at %s" % auth_src)
if not os.path.isdir(config_src):
raise ValueError("no config source at %s" % config_src)
sessdir = session_dir_for(parent, pid)
cfgdir = os.path.join(sessdir, "muse")
if os.path.exists(sessdir):
if session_liveness(sessdir) == "live":
raise RuntimeError("session slot %s is live" % sessdir)
shutil.rmtree(sessdir, ignore_errors=True)
os.makedirs(cfgdir)
for entry in sorted(os.listdir(config_src)):
if entry == AUTH_FILENAME:
continue
target = os.path.join(config_src, entry)
try:
os.symlink(target, os.path.join(cfgdir, entry))
except OSError:
pass
with open(auth_src, "rb") as f:
creds = f.read()
if not _write_private_bytes(os.path.join(cfgdir, AUTH_FILENAME), creds):
raise OSError("cannot plant auth.json in %s" % cfgdir)
with open(os.path.join(sessdir, BIND_FILENAME), "w") as f:
json.dump({"profile": profile, "pid": pid,
"created": _now(), "auth_src": auth_src}, f, indent=1)
return sessdir
def record_session_profile(config_src, session_id, profile):
"""Note session->profile for resume guards. Returns True on success."""
path = os.path.join(config_src, "session_profiles.json")
try:
with open(path) as f:
data = json.load(f)
if not isinstance(data, dict):
data = {}
except (OSError, ValueError):
data = {}
data[str(session_id)] = str(profile)
try:
tmp = "%s.tmp.%d" % (path, os.getpid())
with open(tmp, "w") as f:
json.dump(data, f, indent=1)
os.replace(tmp, path)
return True
except OSError:
return False
def save_session(sessdir, config_src=None):
"""Sync a session copy back to its profile when newer.
Returns {"status", ...}; statuses: synced | skipped-stale |
skipped-missing | no-bind. Never raises, never logs token bytes.
"""
config_src = config_src or default_config_src()
bind = read_bind(sessdir)
if not bind or not bind.get("profile"):
return {"status": "no-bind", "sessdir": sessdir}
profile = bind["profile"]
sess_auth = os.path.join(sessdir, "muse", AUTH_FILENAME)
dest = os.path.realpath(profile_auth_path(config_src, profile))
if not os.path.isfile(sess_auth):
return {"status": "skipped-missing", "sessdir": sessdir,
"profile": profile}
try:
sess_mtime = os.path.getmtime(sess_auth)
except OSError:
return {"status": "skipped-missing", "sessdir": sessdir,
"profile": profile}
try:
dest_mtime = os.path.getmtime(dest)
except OSError:
dest_mtime = -1
if dest_mtime >= sess_mtime:
return {"status": "skipped-stale", "sessdir": sessdir,
"profile": profile, "session_fp": _fingerprint(sess_auth),
"profile_fp": _fingerprint(dest)}
try:
with open(sess_auth, "rb") as f:
creds = f.read()
except OSError:
return {"status": "skipped-missing", "sessdir": sessdir,
"profile": profile}
try:
os.makedirs(os.path.dirname(dest), exist_ok=True)
except OSError:
pass
if not _write_private_bytes(dest, creds):
return {"status": "error", "sessdir": sessdir, "profile": profile}
return {"status": "synced", "sessdir": sessdir, "profile": profile,
"session_fp": _fingerprint(sess_auth),
"profile_fp": _fingerprint(dest)}
def reap(parent="/tmp", config_src=None):
"""Sync + remove dead bound sessions. Returns {"reaped", "live"}."""
config_src = config_src or default_config_src()
reaped, live = [], []
for sessdir in list_bound(parent):
if session_liveness(sessdir) == "live":
live.append(sessdir)
continue
res = save_session(sessdir, config_src)
shutil.rmtree(sessdir, ignore_errors=True)
reaped.append({"sessdir": sessdir, "save": res["status"],
"profile": res.get("profile")})
return {"reaped": reaped, "live": live}
def launch(profile=None, auth_file=None, session_id=None, config_src=None,
cmd=None, parent="/tmp", dry_run=False, _exec=os.execvpe):
"""Bind then exec. With dry_run, return the plan without exec'ing."""
config_src = config_src or default_config_src()
if auth_file:
auth_src = os.path.realpath(auth_file)
elif profile:
auth_src = profile_auth_path(config_src, profile)
else:
raise ValueError("need --profile or --auth-file")
if not os.path.isfile(auth_src):
raise ValueError("no credentials at %s" % auth_src)
pid = os.getpid()
if dry_run:
return {"sessdir": session_dir_for(parent, pid),
"xdg_config_home": session_dir_for(parent, pid),
"profile": profile, "auth_src": auth_src,
"cmd": cmd or []}
# Reap BEFORE building: our own fresh dir would read as dead (the
# launcher is python, not muse, until it execs) and eat itself.
reap(parent=parent, config_src=config_src)
sessdir = build_session_dir(parent, pid, config_src, auth_src,
profile or "explicit")
if session_id:
record_session_profile(config_src, session_id,
profile or "explicit")
env = dict(os.environ)
env["XDG_CONFIG_HOME"] = sessdir
env["MUSE_SESSION_BIND_DIR"] = sessdir
_exec(cmd[0], cmd, env)
return None # unreachable; exec replaces the image
def cmd_status(args):
config_src = args.config_src or default_config_src()
rows = []
for sessdir in list_bound(args.parent):
bind = read_bind(sessdir) or {}
sess_auth = os.path.join(sessdir, "muse", AUTH_FILENAME)
prof_auth = profile_auth_path(config_src, bind.get("profile", ""))
rows.append({"sessdir": sessdir, "profile": bind.get("profile"),
"pid": bind.get("pid"),
"liveness": session_liveness(sessdir),
"session_fp": _fingerprint(sess_auth),
"profile_fp": _fingerprint(prof_auth)})
if args.json:
print(json.dumps({"sessions": rows}, indent=1))
else:
if not rows:
print("No bound sessions under %s." % args.parent)
return 0
for r in rows:
print("%s profile=%s pid=%s %s session=%s profile=%s" % (
r["sessdir"], r["profile"], r["pid"], r["liveness"],
r["session_fp"], r["profile_fp"]))
return 0
def main(argv=None):
ap = argparse.ArgumentParser(
prog="muse_session_bind",
description="Per-session credential isolation for muse.")
ap.add_argument("--parent", default="/tmp",
help="Session dir parent (default /tmp).")
ap.add_argument("--config-src", default=None,
help="Global config source (default ~/.config/muse).")
sub = ap.add_subparsers(dest="command", required=True)
p_l = sub.add_parser("launch", help="Bind a profile, then exec muse.")
p_l.add_argument("--profile", default=None)
p_l.add_argument("--auth-file", default=None)
p_l.add_argument("--session-id", default=None)
p_l.add_argument("--dry-run", action="store_true")
p_l.add_argument("cmd", nargs=argparse.REMAINDER,
help="Command after --, e.g. -- muse-code")
p_s = sub.add_parser("save", help="Sync session tokens back to profile.")
g = p_s.add_mutually_exclusive_group(required=True)
g.add_argument("--pid", type=int)
g.add_argument("--dir")
g.add_argument("--all", action="store_true")
p_s.add_argument("--json", action="store_true")
p_r = sub.add_parser("reap", help="Sync + remove dead sessions.")
p_r.add_argument("--json", action="store_true")
p_st = sub.add_parser("status", help="List bound sessions.")
p_st.add_argument("--json", action="store_true")
args = ap.parse_args(argv)
config_src = args.config_src or default_config_src()
if args.command == "launch":
cmd = [c for c in args.cmd if c != "--"]
if not cmd and not args.dry_run:
print("launch needs a command: launch ... -- muse-code [...]",
file=sys.stderr)
return 2
try:
plan = launch(profile=args.profile, auth_file=args.auth_file,
session_id=args.session_id, config_src=config_src,
cmd=cmd, parent=args.parent,
dry_run=args.dry_run)
except (ValueError, RuntimeError, OSError) as e:
print("launch refused: %s" % (e,), file=sys.stderr)
return 1
if args.dry_run:
print(json.dumps(plan, indent=1))
return 0
if args.command == "save":
if args.pid is not None:
targets = [session_dir_for(args.parent, args.pid)]
elif args.dir:
targets = [args.dir]
else:
targets = list_bound(args.parent)
results = [save_session(t, config_src) for t in targets]
if args.json:
print(json.dumps({"saved": results}, indent=1))
else:
for r in results:
print("%s: %s" % (r["sessdir"], r["status"]))
return 0
if args.command == "reap":
res = reap(parent=args.parent, config_src=config_src)
if args.json:
print(json.dumps(res, indent=1))
else:
for r in res["reaped"]:
print("reaped %s (%s)" % (r["sessdir"], r["save"]))
if not res["reaped"]:
print("Nothing to reap.")
return 0
if args.command == "status":
return cmd_status(args)
return 2
if __name__ == "__main__":
sys.exit(main())
+7 -2
View File
@@ -954,7 +954,7 @@ def cmd_runtime(args):
print()
return
headers = ["SOCKET", "SESSION", "NODE", "PANE", "CMD",
"STATE", "APPROVE", "WATCHER"]
"STATE", "APPROVE", "MODE", "WATCHER"]
table = []
for r in rows:
state = r["state"]
@@ -964,15 +964,20 @@ def cmd_runtime(args):
if r["is_muse"]:
approve = (badge_ok("YES") if r["auto_approve"]
else badge_err("NO"))
mode = r.get("permission_mode") or "default"
if r.get("permission_bypass") and mode != "yolo":
mode += "!"
mode = mode[:14]
else:
approve = badge_dim("-")
mode = badge_dim("-")
watcher = (badge_ok("ALIVE %s" % r["watcher_pid"])
if r["watcher_alive"] else badge_dim("-"))
table.append([os.path.basename(r["socket"]),
"%s:%s" % (r["session"], r["window"]),
r["node"] or badge_dim("-"),
r["pane"], (r["cmd"] or "")[:26], state,
approve, watcher])
approve, mode, watcher])
print_table(headers, table)
print()