chore(fleet): sync operator memory, hatch menu dialogs, and watchdog alerts

This commit is contained in:
operator
2026-10-07 00:25:51 +00:00
parent 0065d11e97
commit 34b0ef9fe2
38 changed files with 2205 additions and 444 deletions
+62
View File
@@ -35,6 +35,58 @@ TARGET_MD_FILES = [
"IDENTITY.md", "IDENTITY.md",
] ]
class MDValidationError(ValueError):
"""An md account/filename/path failed safety validation.
box-ctl.py maps this to BAD_NAME; it is always raised before any
gateway call or filesystem write.
"""
MD_ACCOUNT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$")
MD_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
MD_SUBPATH_RE = re.compile(r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$")
def validate_account(account: str) -> str:
"""Reject account values that could escape the cookies/config path."""
if not isinstance(account, str) or not MD_ACCOUNT_RE.fullmatch(account):
raise MDValidationError(
"Invalid agent account %r: must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$"
% (account,))
return account
def validate_filename(filename: str, template_only: bool = False) -> str:
"""Reject filenames that could escape the md directory.
Template flows (diff/amend/append/pull) additionally require one of
TARGET_MD_FILES, since they index into shared/operators/.
"""
if template_only:
if filename not in TARGET_MD_FILES:
raise MDValidationError(
"Unknown shared template %r: must be one of %s"
% (filename, sorted(TARGET_MD_FILES)))
return filename
if not isinstance(filename, str) or filename in (".", "..") \
or not MD_FILENAME_RE.fullmatch(filename):
raise MDValidationError(
"Invalid filename %r: plain basename, no directories" % (filename,))
return filename
def validate_subpath(path: str) -> str:
"""Reject list paths that escape the container root ('' = root)."""
if path in (None, ""):
return ""
if not isinstance(path, str) or not MD_SUBPATH_RE.fullmatch(path) \
or ".." in path.split("/"):
raise MDValidationError(
"Invalid list path %r: subdir without '..'" % (path,))
return path
# Tunnel / Port inventory # Tunnel / Port inventory
TUNNEL_PORTS = { TUNNEL_PORTS = {
"muse-main": {"port": 2224, "terminal": 7681, "user": "muse"}, "muse-main": {"port": 2224, "terminal": 7681, "user": "muse"},
@@ -49,6 +101,7 @@ TUNNEL_PORTS = {
def get_gateway(account: str) -> "Gateway": def get_gateway(account: str) -> "Gateway":
"""Obtain an authenticated Gateway connection for an account.""" """Obtain an authenticated Gateway connection for an account."""
validate_account(account)
if not Gateway: if not Gateway:
raise RuntimeError("muse_cli.gateway module is not available") raise RuntimeError("muse_cli.gateway module is not available")
conf_dir = Path.home() / ".config" / "muse-cli" / account conf_dir = Path.home() / ".config" / "muse-cli" / account
@@ -63,6 +116,7 @@ def get_gateway(account: str) -> "Gateway":
def list_files(account: str, path: str = "") -> list: def list_files(account: str, path: str = "") -> list:
"""List files in the agent container filesystem via Hatch.""" """List files in the agent container filesystem via Hatch."""
path = validate_subpath(path)
gw = get_gateway(account) gw = get_gateway(account)
res = gw.call_json("fs.list", body={"path": path}) res = gw.call_json("fs.list", body={"path": path})
return res.get("entries", []) return res.get("entries", [])
@@ -70,6 +124,7 @@ def list_files(account: str, path: str = "") -> list:
def read_md(account: str, filename: str, max_bytes: int = 200000) -> dict: def read_md(account: str, filename: str, max_bytes: int = 200000) -> dict:
"""Read a markdown file from the agent container via Hatch.""" """Read a markdown file from the agent container via Hatch."""
validate_filename(filename)
gw = get_gateway(account) gw = get_gateway(account)
offset = 0 offset = 0
chunks = [] chunks = []
@@ -100,6 +155,7 @@ def read_md(account: str, filename: str, max_bytes: int = 200000) -> dict:
def write_md(account: str, filename: str, text: str, overwrite: bool = True, append: bool = False) -> dict: def write_md(account: str, filename: str, text: str, overwrite: bool = True, append: bool = False) -> dict:
"""Write content to a file in the agent container via Hatch.""" """Write content to a file in the agent container via Hatch."""
validate_filename(filename)
gw = get_gateway(account) gw = get_gateway(account)
body = { body = {
"path": filename, "path": filename,
@@ -121,6 +177,8 @@ def write_md(account: str, filename: str, text: str, overwrite: bool = True, app
def audit_agents(accounts: list = None) -> dict: def audit_agents(accounts: list = None) -> dict:
"""Audit markdown files and operational DRIVE across fleet agents.""" """Audit markdown files and operational DRIVE across fleet agents."""
accounts = accounts or VALID_ACCOUNTS accounts = accounts or VALID_ACCOUNTS
for acct in accounts:
validate_account(acct)
results = {} results = {}
for acct in accounts: for acct in accounts:
@@ -213,6 +271,7 @@ def audit_agents(accounts: list = None) -> dict:
def diff_md(account: str, filename: str) -> dict: def diff_md(account: str, filename: str) -> dict:
"""Compare an agent's container file against the shared operator template.""" """Compare an agent's container file against the shared operator template."""
validate_filename(filename, template_only=True)
local_path = SHARED_OPERATORS / filename local_path = SHARED_OPERATORS / filename
if not local_path.exists(): if not local_path.exists():
raise FileNotFoundError(f"Local template {local_path} not found") raise FileNotFoundError(f"Local template {local_path} not found")
@@ -242,6 +301,7 @@ def diff_md(account: str, filename: str) -> dict:
def amend_md(filename: str, content: str, author: str = "operator", reason: str = "") -> dict: def amend_md(filename: str, content: str, author: str = "operator", reason: str = "") -> dict:
"""Amend a centralized shared operator template in shared/operators/ with safety validation and git commit.""" """Amend a centralized shared operator template in shared/operators/ with safety validation and git commit."""
import subprocess import subprocess
validate_filename(filename, template_only=True)
local_path = SHARED_OPERATORS / filename local_path = SHARED_OPERATORS / filename
if not local_path.exists(): if not local_path.exists():
@@ -296,6 +356,7 @@ def amend_md(filename: str, content: str, author: str = "operator", reason: str
def append_md(filename: str, text: str, author: str = "operator", section: str = None) -> dict: def append_md(filename: str, text: str, author: str = "operator", section: str = None) -> dict:
"""Safely append an amendment or lesson to a centralized shared template.""" """Safely append an amendment or lesson to a centralized shared template."""
validate_filename(filename, template_only=True)
local_path = SHARED_OPERATORS / filename local_path = SHARED_OPERATORS / filename
if not local_path.exists(): if not local_path.exists():
raise FileNotFoundError(f"Shared operator file {filename} does not exist in {SHARED_OPERATORS}") raise FileNotFoundError(f"Shared operator file {filename} does not exist in {SHARED_OPERATORS}")
@@ -311,6 +372,7 @@ def append_md(filename: str, text: str, author: str = "operator", section: str =
def pull_md(account: str, filename: str) -> dict: def pull_md(account: str, filename: str) -> dict:
"""Pull the canonical centralized template from shared/operators/ into an agent's container.""" """Pull the canonical centralized template from shared/operators/ into an agent's container."""
validate_filename(filename, template_only=True)
local_path = SHARED_OPERATORS / filename local_path = SHARED_OPERATORS / filename
if not local_path.exists(): if not local_path.exists():
raise FileNotFoundError(f"Shared operator file {filename} does not exist in {SHARED_OPERATORS}") raise FileNotFoundError(f"Shared operator file {filename} does not exist in {SHARED_OPERATORS}")
+134 -18
View File
@@ -13,10 +13,12 @@ Supports both:
4. Continuous watch & background integration into fleet status and loop health. 4. Continuous watch & background integration into fleet status and loop health.
""" """
import itertools
import json import json
import os import os
import re import re
import sys import sys
import threading
import time import time
import urllib.request import urllib.request
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -48,15 +50,28 @@ def load_responded_waits() -> dict:
return {} return {}
def save_responded_waits(data: dict) -> None: def _atomic_write_json(path: Path, data: dict) -> None:
"""Persist the responded-waits map (best effort).""" """Write JSON atomically via tmp + replace (best effort).
Plain write_text from concurrent writers (timers, box-ctl, TUI
threads) can interleave and corrupt the file; readers then fall
back to {} and silently drop state. Tmp names carry pid + thread
ident so concurrent writers never share a temp file.
"""
try: try:
RESPONDED_WAITS_FILE.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
RESPONDED_WAITS_FILE.write_text(json.dumps(data, indent=1)) tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{threading.get_ident()}")
tmp.write_text(json.dumps(data, indent=1))
os.replace(tmp, path)
except Exception: except Exception:
pass pass
def save_responded_waits(data: dict) -> None:
"""Persist the responded-waits map (best effort)."""
_atomic_write_json(RESPONDED_WAITS_FILE, data)
def load_first_seen_waits() -> dict: def load_first_seen_waits() -> dict:
"""Load map of when input waits were first observed: {node: {task: iso_timestamp}}.""" """Load map of when input waits were first observed: {node: {task: iso_timestamp}}."""
try: try:
@@ -69,11 +84,7 @@ def load_first_seen_waits() -> dict:
def save_first_seen_waits(data: dict) -> None: def save_first_seen_waits(data: dict) -> None:
"""Persist first-seen input waits map.""" """Persist first-seen input waits map."""
try: _atomic_write_json(FIRST_SEEN_WAITS_FILE, data)
FIRST_SEEN_WAITS_FILE.parent.mkdir(parents=True, exist_ok=True)
FIRST_SEEN_WAITS_FILE.write_text(json.dumps(data, indent=1))
except Exception:
pass
def is_wait_responded(node: str, task: str) -> bool: def is_wait_responded(node: str, task: str) -> bool:
@@ -466,9 +477,15 @@ def get_cdp_ws(node: str, page_idx: int = 0, timeout: float = 3.0):
return ws, target_page return ws, target_page
_cdp_req_ids = itertools.count(1)
def cdp_evaluate(ws, js_expr: str, await_promise: bool = False, timeout: float = 3.0): def cdp_evaluate(ws, js_expr: str, await_promise: bool = False, timeout: float = 3.0):
"""Evaluate a JavaScript expression via CDP Runtime.evaluate and return the result value.""" """Evaluate a JavaScript expression via CDP Runtime.evaluate and return the result value."""
req_id = int(time.time() * 1000) % 100000 # Monotonic ids: millisecond-clock ids collide for rapid successive
# evaluates, letting a stale buffered response be misattributed to
# the wrong call (e.g. verify-after-click reading the click result).
req_id = next(_cdp_req_ids)
msg = { msg = {
"id": req_id, "id": req_id,
"method": "Runtime.evaluate", "method": "Runtime.evaluate",
@@ -575,15 +592,33 @@ JS_INSPECT_APPROVALS = """(() => {
} }
} }
// Background queued approvals surface (e.g. "2 tasks need review", "Review")
const bgSurface = document.querySelector('[data-hatch-background-approval-surface="true"]');
let bgTasksCount = 0;
let bgText = '';
if (bgSurface) {
bgText = (bgSurface.innerText || '').trim();
const m = bgText.match(/(\\d+)\\s+tasks?\\s+need\\s+review/i);
if (m) {
bgTasksCount = parseInt(m[1], 10);
} else if (/a\\s+task\\s+needs\\s+review/i.test(bgText) || /tasks?\\s+need\\s+review/i.test(bgText)) {
bgTasksCount = 1;
}
}
const hasPendingApproval = (!!activeCard && (hasAllowOnce || hasDeny)) || (bgTasksCount > 0);
return JSON.stringify({ return JSON.stringify({
has_pending: !!activeCard && (hasAllowOnce || hasDeny), has_pending: hasPendingApproval,
card_text: cardText.slice(0, 1000), card_text: cardText.slice(0, 1000) || bgText,
buttons: buttons, buttons: buttons,
has_allow_once: hasAllowOnce, has_allow_once: hasAllowOnce || (bgTasksCount > 0),
has_always_allow: hasAlwaysAllow, has_always_allow: hasAlwaysAllow,
has_deny: hasDeny, has_deny: hasDeny,
history: historyBadges.slice(0, 5), history: historyBadges.slice(0, 5),
input_waits: inputWaits.slice(0, 10) input_waits: inputWaits.slice(0, 10),
bg_tasks_count: bgTasksCount,
bg_text: bgText
}); });
})()""" })()"""
@@ -635,30 +670,68 @@ def inspect_node_approvals(node: str) -> dict:
"error": str(e), "error": str(e),
"has_pending": False, "has_pending": False,
"host_cdp_ok": host_ok, "host_cdp_ok": host_ok,
"title": "Node unreachable",
"purpose": "",
"ip": None,
"target": "-",
"is_trusted": False,
"buttons": [],
"has_allow_once": False,
"has_always_allow": False,
"has_deny": False,
"raw_text": "",
"history": [],
"input_waits": [],
"page_title": "",
"page_url": "",
"ws_url": "",
} }
all_input_waits = [] all_input_waits = []
first_page = pages[0] first_page = pages[0]
last_err = None last_err = None
inspected_ok = False
for page in pages: for page in pages:
ws_url = page.get("webSocketDebuggerUrl") ws_url = page.get("webSocketDebuggerUrl")
if not ws_url: if not ws_url:
if last_err is None:
last_err = Exception("page has no webSocketDebuggerUrl")
continue continue
ws = None ws = None
try: try:
ws = websocket.create_connection(ws_url, timeout=2.0) ws = websocket.create_connection(ws_url, timeout=2.0)
val_str = cdp_evaluate(ws, JS_INSPECT_APPROVALS, timeout=2.5) val_str = cdp_evaluate(ws, JS_INSPECT_APPROVALS, timeout=2.5)
if val_str and isinstance(val_str, str):
data = json.loads(val_str)
# If a background review banner is present and active card wasn't mounted, click review to reveal card
if data.get("bg_tasks_count", 0) > 0 and (not data.get("buttons") or "task" in (data.get("card_text") or "").lower()):
js_expand = """(() => {
const bgBtn = document.querySelector('[data-pel-click="chat_background_approval_review"]') ||
document.querySelector('[data-hatch-background-approval-surface="true"] button');
if (bgBtn) { bgBtn.click(); return 'CLICKED'; }
return 'NO_BTN';
})()"""
exp_res = cdp_evaluate(ws, js_expand, timeout=1.5)
if exp_res == "CLICKED":
time.sleep(0.35)
val_str2 = cdp_evaluate(ws, JS_INSPECT_APPROVALS, timeout=2.5)
if val_str2 and isinstance(val_str2, str):
val_str = val_str2
ws.close() ws.close()
ws = None ws = None
if not val_str or not isinstance(val_str, str): if not val_str or not isinstance(val_str, str):
if last_err is None:
last_err = Exception("empty or invalid CDP evaluate result")
continue continue
data = json.loads(val_str) data = json.loads(val_str)
inspected_ok = True
if data.get("input_waits"): if data.get("input_waits"):
all_input_waits.extend(data["input_waits"]) all_input_waits.extend(data["input_waits"])
if data.get("has_pending"): if data.get("has_pending"):
card_text = data.get("card_text", "") card_text = data.get("card_text", "")
bg_tasks_count = data.get("bg_tasks_count", 0)
ip = None ip = None
target = None target = None
m_t = re.search( m_t = re.search(
@@ -678,8 +751,14 @@ def inspect_node_approvals(node: str) -> dict:
target = m_domain.group(0) target = m_domain.group(0)
lines = [line.strip() for line in card_text.split("\n") if line.strip()] lines = [line.strip() for line in card_text.split("\n") if line.strip()]
if not lines and bg_tasks_count:
title = f"{bg_tasks_count} task(s) need review"
purpose = "Background tasks held up on review surface. Click 'Review' or allow to inspect."
else:
title = redact_sensitive(lines[0] if lines else "Permission request") title = redact_sensitive(lines[0] if lines else "Permission request")
purpose = redact_sensitive(lines[1] if len(lines) > 1 else "") purpose = redact_sensitive(lines[1] if len(lines) > 1 else "")
if bg_tasks_count > 0 and "need review" not in purpose.lower() and "need review" not in title.lower():
purpose = f"{purpose} [{bg_tasks_count} queued task(s) awaiting review]".strip()
is_trusted = is_trusted_target(target or ip, card_text) is_trusted = is_trusted_target(target or ip, card_text)
@@ -696,6 +775,7 @@ def inspect_node_approvals(node: str) -> dict:
"has_allow_once": data.get("has_allow_once", False), "has_allow_once": data.get("has_allow_once", False),
"has_always_allow": data.get("has_always_allow", False), "has_always_allow": data.get("has_always_allow", False),
"has_deny": data.get("has_deny", False), "has_deny": data.get("has_deny", False),
"bg_tasks_count": bg_tasks_count,
"raw_text": redact_sensitive(card_text), "raw_text": redact_sensitive(card_text),
"history": data.get("history", []), "history": data.get("history", []),
"input_waits": all_input_waits, "input_waits": all_input_waits,
@@ -789,12 +869,24 @@ def inspect_node_approvals(node: str) -> dict:
"key_request": key_req, "key_request": key_req,
} }
status = "INPUT_WAIT" if unique_waits else ("ERROR" if last_err and not first_page else "CLEAR") # A node whose pages all failed inspection must report ERROR, never a
# false CLEAR that hides pending approvals. (The old `last_err and not
# first_page` guard was dead: first_page is always truthy here.)
if unique_waits:
status = "INPUT_WAIT"
title = "No pending approvals"
elif not inspected_ok:
status = "ERROR"
title = "Approval inspection failed"
else:
status = "CLEAR"
title = "No pending approvals"
return { return {
"node": node, "node": node,
"status": status, "status": status,
"error": str(last_err) if status == "ERROR" and last_err else "",
"has_pending": False, "has_pending": False,
"title": "No pending approvals", "title": title,
"purpose": "", "purpose": "",
"ip": None, "ip": None,
"target": "-", "target": "-",
@@ -936,23 +1028,47 @@ def allow_node_approval(node: str, always: bool = False, force: bool = False, ca
btn.click(); btn.click();
return 'CLICKED_ALLOW'; return 'CLICKED_ALLOW';
} }
const bgBtn = document.querySelector('[data-pel-click="chat_background_approval_review"]') ||
document.querySelector('[data-hatch-background-approval-surface="true"] button');
if (bgBtn) {
bgBtn.click();
return 'CLICKED_REVIEW_SURFACE';
}
return 'NOT_FOUND'; return 'NOT_FOUND';
})()""" })()"""
click_res = cdp_evaluate(ws, js_click, timeout=3.0) click_res = cdp_evaluate(ws, js_click, timeout=3.0)
if click_res == "CLICKED_REVIEW_SURFACE":
time.sleep(0.6)
click_res2 = cdp_evaluate(ws, """(() => {
const primary = document.querySelector('button[data-hatch-approval-primary-action="true"]');
if (primary) { primary.click(); return 'CLICKED_PRIMARY'; }
const btns = Array.from(document.querySelectorAll('button'));
const btn = btns.find(b => {
const t = (b.innerText||'').trim().toLowerCase();
return t === 'allow once' || t === 'allow';
});
if (btn) { btn.click(); return 'CLICKED_ALLOW'; }
return 'NOT_FOUND';
})()""", timeout=2.0)
if click_res2 != "NOT_FOUND":
click_res = click_res2
# Verify dismissal # Verify dismissal
time.sleep(0.8) time.sleep(0.8)
js_verify = """(() => { js_verify = """(() => {
const primary = document.querySelector('button[data-hatch-approval-primary-action="true"]'); const primary = document.querySelector('button[data-hatch-approval-primary-action="true"]');
if (primary) return 'STILL_PRESENT'; if (primary) return 'STILL_PRESENT';
const headers = document.querySelectorAll('[data-testid="approval-panel-header"]'); const headers = document.querySelectorAll('[data-testid="approval-panel-header"]');
return headers.length === 0 ? 'DISMISSED' : 'STILL_PRESENT'; if (headers.length > 0) return 'STILL_PRESENT';
const bgSurface = document.querySelector('[data-hatch-background-approval-surface="true"]');
return bgSurface ? 'QUEUED_PRESENT' : 'DISMISSED';
})()""" })()"""
verify_res = cdp_evaluate(ws, js_verify, timeout=2.0) verify_res = cdp_evaluate(ws, js_verify, timeout=2.0)
ws.close() ws.close()
dismissed = verify_res == "DISMISSED" dismissed = verify_res in ("DISMISSED", "QUEUED_PRESENT")
mode = "always" if always else "allow_once" mode = "always" if always else "allow_once"
log_box_ctl( log_box_ctl(
"approval-allow", "approval-allow",
+581 -4
View File
@@ -204,11 +204,78 @@ case "$cmd" in
args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1], 'target': sys.argv[2], 'limit': int(sys.argv[3])}))" "$AGENT" "$target" "$limit") args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1], 'target': sys.argv[2], 'limit': int(sys.argv[3])}))" "$AGENT" "$target" "$limit")
call_exec "dm.read" "$args" call_exec "dm.read" "$args"
;; ;;
log)
limit=""
log_agent=""
while [ $# -gt 0 ]; do
case "$1" in
--agent) log_agent="$2"; shift 2 ;;
*) if [ -z "$limit" ]; then limit="$1"; fi; shift ;;
esac
done
limit="${limit:-20}"
args=$(python3 -c "import json,sys; lim=int(sys.argv[1]); ag=sys.argv[2]; print(json.dumps({'limit':lim,**({'agent':ag} if ag else {})}))" "$limit" "$log_agent")
call_exec "dm.log" "$args"
;;
ack)
to=""
from_agent=""
sidechat=""
allow_main=""
ref_id=""
while [ $# -gt 0 ]; do
case "$1" in
--to) to="$2"; shift 2 ;;
--sender|--from) from_agent="$2"; shift 2 ;;
--sidechat) sidechat="$2"; shift 2 ;;
--allow-main-chat) allow_main="1"; shift ;;
*) if [ -z "$ref_id" ]; then ref_id="$1"; fi; shift ;;
esac
done
ref_id="${ref_id:?usage: box dm ack <id> --to <agent> --sender <agent> [--sidechat <name>] [--allow-main-chat]}"
from_agent="${from_agent:-$AGENT}"
args=$(python3 -c "
import json, sys
ref, to, sender, sc, main = sys.argv[1:6]
args = {'id': ref, 'to': to, 'sender': sender}
if sc:
args['sidechat'] = sc
if main:
args['allow_main_chat'] = True
print(json.dumps(args))
" "$ref_id" "$to" "$from_agent" "$sidechat" "$allow_main")
call_exec "dm.ack" "$args"
;;
*) *)
echo "Usage: box dm send|read ..." echo "Usage: box dm send|read|log|ack ..."
;; ;;
esac esac
;; ;;
notify)
target_agent="${1:?usage: box notify <agent> [--sidechat <name>] [--sender <agent>] <message...>}"
shift
sidechat=""
sender=""
while [ $# -gt 0 ]; do
case "$1" in
--sidechat) sidechat="$2"; shift 2 ;;
--sender|--from) sender="$2"; shift 2 ;;
*) break ;;
esac
done
msg="${*:?usage: box notify <agent> [--sidechat <name>] [--sender <agent>] <message...>}"
args=$(python3 -c "
import json, sys
agent, message, sc, sender = sys.argv[1:5]
args = {'agent': agent, 'message': message}
if sc:
args['sidechat'] = sc
if sender:
args['sender'] = sender
print(json.dumps(args))
" "$target_agent" "$msg" "$sidechat" "$sender")
call_exec "notify.send" "$args"
;;
thread) thread)
sub="${1:-list}" sub="${1:-list}"
shift || true shift || true
@@ -262,6 +329,70 @@ case "$cmd" in
args=$(python3 -c "import json, sys; print(json.dumps({'job': sys.argv[1]}))" "$name") args=$(python3 -c "import json, sys; print(json.dumps({'job': sys.argv[1]}))" "$name")
call_exec "cron.run" "$args" call_exec "cron.run" "$args"
;; ;;
put)
name="${1:?usage: box cron put <name> '<json-definition>'}"
json_def="${2:?usage: box cron put <name> '<json-definition>'}"
args=$(python3 -c "
import json, sys
try:
definition = json.loads(sys.argv[2])
except Exception as e:
sys.stderr.write('invalid job JSON: %s\n' % e)
sys.exit(2)
print(json.dumps({'name': sys.argv[1], 'definition': definition}))
" "$name" "$json_def")
call_exec "job.put" "$args"
;;
trigger)
name="${1:?usage: box cron trigger <name>}"
args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name")
call_exec "job.trigger" "$args"
;;
chain)
from_job="${1:?usage: box cron chain <from> <to> [--on-failure]}"
shift || true
on_failure=""
to_job=""
while [ $# -gt 0 ]; do
case "$1" in
--on-failure) on_failure="1"; shift ;;
*) if [ -z "$to_job" ]; then to_job="$1"; fi; shift ;;
esac
done
to_job="${to_job:?usage: box cron chain <from> <to> [--on-failure]}"
args=$(python3 -c "
import json, sys
frm, to, onfail = sys.argv[1:4]
args = {'from': frm, 'to': to}
if onfail:
args['on_failure'] = True
print(json.dumps(args))
" "$from_job" "$to_job" "$on_failure")
call_exec "job.chain" "$args"
;;
next)
job_id=""
success=""
while [ $# -gt 0 ]; do
case "$1" in
--success) success="1"; shift ;;
--fail) success="0"; shift ;;
*) if [ -z "$job_id" ]; then job_id="$1"; fi; shift ;;
esac
done
job_id="${job_id:?usage: box cron next <job-id> [--success|--fail]}"
args=$(python3 -c "
import json, sys
jid, success = sys.argv[1:3]
args = {'job_id': jid}
if success == '1':
args['success'] = True
elif success == '0':
args['success'] = False
print(json.dumps(args))
" "$job_id" "$success")
call_exec "job.next" "$args"
;;
timer-create) timer-create)
name="${1:?usage: box cron timer-create <name>}" name="${1:?usage: box cron timer-create <name>}"
args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name") args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name")
@@ -273,7 +404,7 @@ case "$cmd" in
call_exec "cron.timer_start" "$args" call_exec "cron.timer_start" "$args"
;; ;;
*) *)
echo "Usage: box cron runs|status|view|run|timer-create|timer-start ..." echo "Usage: box cron runs|status|view|run|put|trigger|chain|next|timer-create|timer-start ..."
;; ;;
esac esac
;; ;;
@@ -291,6 +422,16 @@ case "$cmd" in
args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name") args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name")
call_exec "cron.timer_start" "$args" call_exec "cron.timer_start" "$args"
;; ;;
stop)
name="${1:?usage: box timer stop <name>}"
args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name")
call_exec "cron.timer_stop" "$args"
;;
disable)
name="${1:?usage: box timer disable <name>}"
args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name")
call_exec "cron.timer_disable" "$args"
;;
status|view|list) status|view|list)
name="${1:-heartbeat}" name="${1:-heartbeat}"
args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name") args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name")
@@ -304,7 +445,7 @@ case "$cmd" in
call_exec "followup.create" "$args" call_exec "followup.create" "$args"
;; ;;
*) *)
echo "Usage: box timer create|start|status <name> OR box timer in <minutes> <prompt>" echo "Usage: box timer create|start|stop|enable|disable|status <name> OR box timer in <minutes> <prompt>"
;; ;;
esac esac
;; ;;
@@ -353,6 +494,125 @@ case "$cmd" in
;; ;;
esac esac
;; ;;
loop)
sub="${1:?usage: box loop remediate|resolve ...}"
shift || true
case "$sub" in
remediate)
dry=""
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry="1"; shift ;;
*) break ;;
esac
done
if [ -n "$dry" ]; then
args='{"dry_run": true}'
else
args='{}'
fi
call_exec "loop.remediate" "$args"
;;
resolve)
dm_id="${1:?usage: box loop resolve <dm_id> [note...]}"
shift || true
note="$*"
args=$(python3 -c "
import json, sys
dm_id, note = sys.argv[1:3]
args = {'dm_id': dm_id}
if note:
args['note'] = note
print(json.dumps(args))
" "$dm_id" "$note")
call_exec "loop.resolve" "$args"
;;
*)
echo "Usage: box loop remediate [--dry-run] OR box loop resolve <dm_id> [note...]"
;;
esac
;;
strat)
sub="${1:?usage: box strat set|reset ...}"
shift || true
case "$sub" in
set)
stype="${1:?usage: box strat set <type> [options]}"
shift || true
subtype=""
agent=""
track=""
priority=""
timeout_s=""
nudges=""
escalate=""
while [ $# -gt 0 ]; do
case "$1" in
--subtype) subtype="$2"; shift 2 ;;
--agent) agent="$2"; shift 2 ;;
--track) track="$2"; shift 2 ;;
--priority) priority="$2"; shift 2 ;;
--timeout) timeout_s="$2"; shift 2 ;;
--nudges) nudges="$2"; shift 2 ;;
--escalate) escalate="$2"; shift 2 ;;
*) break ;;
esac
done
args=$(python3 -c "
import json, sys
stype, subtype, agent, track, prio, timeout_s, nudges, esc = sys.argv[1:9]
args = {'type': stype}
if subtype:
args['subtype'] = subtype
if agent:
args['agent'] = agent
if track.lower() == 'true':
args['track'] = True
elif track.lower() == 'false':
args['track'] = False
elif track:
sys.stderr.write('track must be true|false\n')
sys.exit(2)
if prio:
args['priority'] = prio
if timeout_s:
args['timeout_s'] = int(timeout_s)
if nudges:
args['nudges'] = int(nudges)
if esc:
args['escalate'] = esc
print(json.dumps(args))
" "$stype" "$subtype" "$agent" "$track" "$priority" "$timeout_s" "$nudges" "$escalate")
call_exec "strat.set" "$args"
;;
reset)
stype="${1:?usage: box strat reset <type> [subtype] [--agent <agent>]}"
shift || true
subtype=""
agent=""
while [ $# -gt 0 ]; do
case "$1" in
--agent) agent="$2"; shift 2 ;;
*) if [ -z "$subtype" ]; then subtype="$1"; fi; shift ;;
esac
done
args=$(python3 -c "
import json, sys
stype, subtype, agent = sys.argv[1:4]
args = {'type': stype}
if subtype:
args['subtype'] = subtype
if agent:
args['agent'] = agent
print(json.dumps(args))
" "$stype" "$subtype" "$agent")
call_exec "strat.reset" "$args"
;;
*)
echo "Usage: box strat set <type> [options] OR box strat reset <type> [subtype] [--agent <agent>]"
;;
esac
;;
vars) vars)
sub="${1:-list}" sub="${1:-list}"
shift || true shift || true
@@ -371,8 +631,26 @@ case "$cmd" in
args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1], 'value': sys.argv[2]}))" "$name" "$val") args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1], 'value': sys.argv[2]}))" "$name" "$val")
call_exec "vars.set" "$args" call_exec "vars.set" "$args"
;; ;;
reset)
name="${1:?usage: box vars reset <name>}"
args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name")
call_exec "vars.reset" "$args"
;;
rollback)
name="${1:?usage: box vars rollback <name> [revision]}"
rev="${2:-}"
args=$(python3 -c "
import json, sys
name, rev = sys.argv[1:3]
args = {'name': name}
if rev:
args['revision'] = int(rev) if rev.isdigit() else rev
print(json.dumps(args))
" "$name" "$rev")
call_exec "vars.rollback" "$args"
;;
*) *)
echo "Usage: box vars list|get|set ..." echo "Usage: box vars list|get|set|reset|rollback ..."
;; ;;
esac esac
;; ;;
@@ -430,6 +708,272 @@ case "$cmd" in
;; ;;
esac esac
;; ;;
git)
sub="${1:-status}"
shift || true
case "$sub" in
status)
call_exec "git.status" "{}"
;;
diff)
stat=""
path=""
while [ $# -gt 0 ]; do
case "$1" in
--stat) stat="1"; shift ;;
--path) path="$2"; shift 2 ;;
*) if [ -z "$path" ]; then path="$1"; fi; shift ;;
esac
done
args=$(python3 -c "
import json, sys
stat, path = sys.argv[1:3]
args = {}
if stat:
args['stat'] = True
if path:
args['path'] = path
print(json.dumps(args))
" "$stat" "$path")
call_exec "git.diff" "$args"
;;
log)
limit=""
path=""
while [ $# -gt 0 ]; do
case "$1" in
--limit) limit="$2"; shift 2 ;;
--path) path="$2"; shift 2 ;;
*) if [ -z "$limit" ]; then limit="$1"; fi; shift ;;
esac
done
limit="${limit:-10}"
args=$(python3 -c "
import json, sys
limit, path = sys.argv[1:3]
args = {'limit': int(limit)}
if path:
args['path'] = path
print(json.dumps(args))
" "$limit" "$path")
call_exec "git.log" "$args"
;;
*)
echo "Usage: box git status|diff|log ..."
;;
esac
;;
tests)
sub="${1:-run}"
shift || true
case "$sub" in
run)
test_mod=""
filt=""
while [ $# -gt 0 ]; do
case "$1" in
--filter) filt="$2"; shift 2 ;;
*) if [ -z "$test_mod" ]; then test_mod="$1"; fi; shift ;;
esac
done
args=$(python3 -c "
import json, sys
mod, filt = sys.argv[1:3]
args = {}
if mod:
args['test'] = mod
if filt:
args['filter'] = filt
print(json.dumps(args))
" "$test_mod" "$filt")
call_exec "tests.run" "$args"
;;
*)
echo "Usage: box tests run [tests.<module>] [--filter <pattern>]"
;;
esac
;;
approvals)
sub="${1:-check}"
shift || true
case "$sub" in
check)
node="${1:-}"
if [ -n "$node" ]; then
args=$(python3 -c "import json, sys; print(json.dumps({'node': sys.argv[1]}))" "$node")
else
args="{}"
fi
call_exec "approval.check" "$args"
;;
allow)
node=""
message=""
main_chat=""
while [ $# -gt 0 ]; do
case "$1" in
--message) message="$2"; shift 2 ;;
--allow-main-chat) main_chat="1"; shift ;;
*) if [ -z "$node" ]; then node="$1"; fi; shift ;;
esac
done
node="${node:?usage: box approvals allow <node> --message <text> [--allow-main-chat]}"
[ -n "$message" ] || { echo "usage: box approvals allow <node> --message <text> [--allow-main-chat]" >&2; exit 2; }
args=$(python3 -c "
import json, sys
node, message, main = sys.argv[1:4]
args = {'node': node, 'message': message}
if main:
args['allow_main_chat'] = True
print(json.dumps(args))
" "$node" "$message" "$main_chat")
call_exec "approval.allow" "$args"
;;
deny)
node=""
message=""
main_chat=""
while [ $# -gt 0 ]; do
case "$1" in
--message) message="$2"; shift 2 ;;
--allow-main-chat) main_chat="1"; shift 2 ;;
*) if [ -z "$node" ]; then node="$1"; fi; shift ;;
esac
done
node="${node:?usage: box approvals deny <node> --message <text> [--allow-main-chat]}"
[ -n "$message" ] || { echo "usage: box approvals deny <node> --message <text> [--allow-main-chat]" >&2; exit 2; }
args=$(python3 -c "
import json, sys
node, message, main = sys.argv[1:4]
args = {'node': node, 'message': message}
if main:
args['allow_main_chat'] = True
print(json.dumps(args))
" "$node" "$message" "$main_chat")
call_exec "approval.deny" "$args"
;;
auto)
node="${1:-}"
if [ -n "$node" ]; then
args=$(python3 -c "import json, sys; print(json.dumps({'node': sys.argv[1]}))" "$node")
else
args="{}"
fi
call_exec "approval.auto" "$args"
;;
*)
echo "Usage: box approvals check|allow|deny|auto ..."
;;
esac
;;
md)
sub="${1:-audit}"
shift || true
case "$sub" in
audit)
args=$(python3 -c "
import json, sys
accts = [a for a in sys.argv[1:] if a]
print(json.dumps({'accounts': accts} if accts else {}))
" "$@")
call_exec "md.audit" "$args"
;;
list)
account="${1:?usage: box md list <account> [path]}"
path="${2:-}"
args=$(python3 -c "import json, sys; print(json.dumps({'account': sys.argv[1], 'path': sys.argv[2]}))" "$account" "$path")
call_exec "md.list" "$args"
;;
read)
account="${1:?usage: box md read <account> <filename>}"
filename="${2:?usage: box md read <account> <filename>}"
args=$(python3 -c "import json, sys; print(json.dumps({'account': sys.argv[1], 'filename': sys.argv[2]}))" "$account" "$filename")
call_exec "md.read" "$args"
;;
diff)
account="${1:?usage: box md diff <account> <filename>}"
filename="${2:?usage: box md diff <account> <filename>}"
args=$(python3 -c "import json, sys; print(json.dumps({'account': sys.argv[1], 'filename': sys.argv[2]}))" "$account" "$filename")
call_exec "md.diff" "$args"
;;
pull)
account="${1:?usage: box md pull <account> <filename>}"
filename="${2:?usage: box md pull <account> <filename>}"
args=$(python3 -c "import json, sys; print(json.dumps({'account': sys.argv[1], 'filename': sys.argv[2]}))" "$account" "$filename")
call_exec "md.pull" "$args"
;;
inject-drive)
account="${1:?usage: box md inject-drive <account>}"
args=$(python3 -c "import json, sys; print(json.dumps({'account': sys.argv[1]}))" "$account")
call_exec "md.inject_drive" "$args"
;;
sync-all)
call_exec "md.sync_all" "{}"
;;
amend)
filename="${1:?usage: box md amend <filename> (--content <text>|--file <path>) [--author <name>] [--reason <why>]}"
shift || true
content=""; content_src=""; author="operator"; reason=""
while [ $# -gt 0 ]; do
case "$1" in
--content) content="$2"; content_src="arg"; shift 2 ;;
--file) content="$2"; content_src="file"; shift 2 ;;
--author) author="$2"; shift 2 ;;
--reason) reason="$2"; shift 2 ;;
*) echo "usage: box md amend <filename> (--content <text>|--file <path>) [--author <name>] [--reason <why>]" >&2; exit 2 ;;
esac
done
[ -n "$content_src" ] || { echo "usage: box md amend <filename> (--content <text>|--file <path>) [--author <name>] [--reason <why>]" >&2; exit 2; }
args=$(python3 -c "
import json, sys
fn, src, val, author, reason = sys.argv[1:6]
content = open(val, encoding='utf-8').read() if src == 'file' else val
args = {'filename': fn, 'content': content, 'author': author}
if reason:
args['reason'] = reason
print(json.dumps(args))
" "$filename" "$content_src" "$content" "$author" "$reason")
call_exec "md.amend" "$args"
;;
append)
filename="${1:?usage: box md append <filename> (--content <text>|--file <path>) [--author <name>] [--section <header>]}"
shift || true
text=""; text_src=""; author="operator"; section=""
while [ $# -gt 0 ]; do
case "$1" in
--content) text="$2"; text_src="arg"; shift 2 ;;
--file) text="$2"; text_src="file"; shift 2 ;;
--author) author="$2"; shift 2 ;;
--section) section="$2"; shift 2 ;;
*) echo "usage: box md append <filename> (--content <text>|--file <path>) [--author <name>] [--section <header>]" >&2; exit 2 ;;
esac
done
[ -n "$text_src" ] || { echo "usage: box md append <filename> (--content <text>|--file <path>) [--author <name>] [--section <header>]" >&2; exit 2; }
args=$(python3 -c "
import json, sys
fn, src, val, author, section = sys.argv[1:6]
text = open(val, encoding='utf-8').read() if src == 'file' else val
args = {'filename': fn, 'text': text, 'author': author}
if section:
args['section'] = section
print(json.dumps(args))
" "$filename" "$text_src" "$text" "$author" "$section")
call_exec "md.append" "$args"
;;
*)
echo "Usage: box md audit|list|read|diff|pull|inject-drive|sync-all|amend|append ..."
;;
esac
;;
unread)
target_agent="${1:-}"
if [ -n "$target_agent" ]; then
args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1]}))" "$target_agent")
else
args="{}"
fi
call_exec "fleet.unread" "$args"
;;
health|fleet-status) health|fleet-status)
call_exec "health.check" "{}" call_exec "health.check" "{}"
;; ;;
@@ -464,21 +1008,54 @@ Usage:
box deploy pipeline <name> box deploy pipeline <name>
box dm send --to <agent> [--target <target>] <message> box dm send --to <agent> [--target <target>] <message>
box dm read [<target=main>] [<limit=10>] box dm read [<target=main>] [<limit=10>]
box dm log [<limit=20>] [--agent <agent>]
box dm ack <id> --to <agent> --sender <agent> [--sidechat <name>]
box notify <agent> [--sidechat <name>] [--sender <agent>] <message...>
box thread list [<agent>] box thread list [<agent>]
box thread view <thread_id> [<limit=15>] box thread view <thread_id> [<limit=15>]
box cron runs box cron runs
box cron status [<name=heartbeat>] box cron status [<name=heartbeat>]
box cron view <name> box cron view <name>
box cron run <name> box cron run <name>
box cron put <name> '<json-definition>'
box cron trigger <name>
box cron chain <from> <to> [--on-failure]
box cron next <job-id> [--success|--fail]
box timer stop <name>
box timer disable <name>
box vars list box vars list
box vars get <name> box vars get <name>
box vars set <name> <value> box vars set <name> <value>
box vars reset <name>
box vars rollback <name> [revision]
box strat set <type> [--subtype S] [--agent A] [--track b] [--priority p] [--timeout N] [--nudges N] [--escalate E]
box strat reset <type> [subtype] [--agent <agent>]
box loop remediate [--dry-run]
box loop resolve <dm_id> [note...]
box files read <path> [lines=100] box files read <path> [lines=100]
box files write <path> <content> box files write <path> <content>
box web fetch <url> box web fetch <url>
box service status <unit> box service status <unit>
box service restart <unit> box service restart <unit>
box git status
box git diff [--stat] [--path <path>]
box git log [<limit=10>] [--path <path>]
box tests run [tests.<module>] [--filter <pattern>]
box md audit [accounts...]
box md list <account> [path]
box md read <account> <filename>
box md diff <account> <filename>
box md pull <account> <filename>
box md inject-drive <account>
box md sync-all
box md amend <filename> (--content <text>|--file <path>) [--author <name>] [--reason <why>]
box md append <filename> (--content <text>|--file <path>) [--author <name>] [--section <header>]
box approvals check [node]
box approvals allow <node> --message <text> [--allow-main-chat]
box approvals deny <node> --message <text> [--allow-main-chat]
box approvals auto [node]
box health box health
box unread [<agent>]
box ping box ping
box ops box ops
+31 -10
View File
@@ -13,10 +13,14 @@
# Pattern mirrors chromebox-watchdog.sh (stage-specific logging, rotation). # Pattern mirrors chromebox-watchdog.sh (stage-specific logging, rotation).
set -euo pipefail set -euo pipefail
LOCK="/tmp/cdp-relay-watchdog.lock" LOCK="/tmp/cdp-relay-watchdog.lock"
exec 9>"$LOCK" # Tests source this file with CDP_RELAY_WATCHDOG_LIB_ONLY=1: they call
if ! flock -n 9; then # helpers without running checks, so no lock is needed.
if [ "${CDP_RELAY_WATCHDOG_LIB_ONLY:-}" != "1" ]; then
exec 9>"$LOCK"
if ! flock -n 9; then
echo "[$(date -u +%FT%TZ)] another relay watchdog run in progress, skipping" >&2 echo "[$(date -u +%FT%TZ)] another relay watchdog run in progress, skipping" >&2
exit 0 exit 0
fi
fi fi
NETVM_BIN="/home/super/Projects/NetVM/bin" NETVM_BIN="/home/super/Projects/NetVM/bin"
@@ -37,16 +41,16 @@ log() { echo "[$(date -u +%FT%TZ)] $*" | tee -a "$LOG"; }
# node -> "veth_ip:port" via netvm-names.sh (hash-derived, don't hardcode) # node -> "veth_ip:port" via netvm-names.sh (hash-derived, don't hardcode)
relay_target() { relay_target() {
local node="$1" local node="$1" reg_port=""
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$NETVM_BIN/netvm-names.sh" . "$NETVM_BIN/netvm-names.sh"
netvm_names "$node" || return 1 netvm_names "$node" || return 1
# CDP_PORT_OVERRIDE pins registry ports; fall back to hash-derived # The registry is the source of truth for ports (new nodes propagate
local port="${CDP_PORT_OVERRIDE:-$CDP_PORT}" # automatically); netvm-names pinning is the fallback.
case "$node" in if reg_port=$("$NETVM_BIN/netvm-registry.py" "$node" 2>/dev/null); then
muse) port=9410 ;; pip) port=9420 ;; 646) port=9430 ;; opm) port=9440 ;; [ -n "$reg_port" ] && CDP_PORT="$reg_port"
esac fi
echo "$PEER_IP:$port" echo "$PEER_IP:$CDP_PORT"
} }
node_port() { echo "${1##*:}"; } node_port() { echo "${1##*:}"; }
@@ -103,8 +107,25 @@ restart_relay() {
fi fi
} }
# Registry-driven node list: every active node gets relay supervision
# (the old hardcoded 4-node list left def/dev unsupervised — 2026-10-06).
watched_nodes() {
"$NETVM_BIN/netvm-registry.py" 2>/dev/null | cut -d: -f1
}
# Allow sourcing for tests without running checks.
if [ "${CDP_RELAY_WATCHDOG_LIB_ONLY:-}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
FAILED=0 FAILED=0
for node in muse pip 646 opm; do NODES="$(watched_nodes)"
if [ -z "$NODES" ]; then
log "FAIL_LOUD: node registry empty/unreadable, skipping run"
exit 1
fi
# shellcheck disable=SC2086 (intended word splitting: one node per word)
for node in $NODES; do
# Stage 1: host veth IP. Fail loud, skip relay restart (pointless). # Stage 1: host veth IP. Fail loud, skip relay restart (pointless).
if ! veth_healthy "$node"; then if ! veth_healthy "$node"; then
read -r veth gw <<< "$(node_veth "$node")" read -r veth gw <<< "$(node_veth "$node")"
+25 -7
View File
@@ -2,10 +2,14 @@
# chrome-error-scan.sh - scan per-profile chrome logs for concerning patterns. # chrome-error-scan.sh - scan per-profile chrome logs for concerning patterns.
# Self-contained: scans, compares against watermark, reports only NEW matches. # Self-contained: scans, compares against watermark, reports only NEW matches.
# #
# Usage: chrome-error-scan.sh [--json] # Usage: chrome-error-scan.sh [--json] [--no-advance]
# Default output: "profile:new_count" lines for profiles with new matches, # Default output: "profile:new_count" lines for profiles with new matches,
# or "OK: no new errors" if clean. # or "OK: no new errors" if clean.
# --json: output JSON {"profile": {"total": N, "new": M}, ...} # --json: output JSON {"profile": {"total": N, "new": M}, ...}
# --no-advance: report against the watermark WITHOUT advancing it.
# Peek-only read for high-frequency pollers (e.g. the web
# surface via `box-ctl chrome-errors --no-advance`). Runs
# without the flag keep the classic advance-on-read semantics.
# #
# Watermark: /home/super/Projects/NetVM/chrome-error-watermark.json # Watermark: /home/super/Projects/NetVM/chrome-error-watermark.json
# Patterns: FATAL, crash, segfault, out of memory (case-insensitive) # Patterns: FATAL, crash, segfault, out of memory (case-insensitive)
@@ -13,6 +17,16 @@
LOGDIR="/home/super/Projects/NetVM" LOGDIR="/home/super/Projects/NetVM"
WATERMARK="$LOGDIR/chrome-error-watermark.json" WATERMARK="$LOGDIR/chrome-error-watermark.json"
AS_JSON=0
NO_ADVANCE=0
for arg in "$@"; do
case "$arg" in
--json) AS_JSON=1 ;;
--no-advance) NO_ADVANCE=1 ;;
*) echo "chrome-error-scan.sh: unknown argument: $arg" >&2; exit 2 ;;
esac
done
# Gather current counts per profile (grep -c prints 0 with exit 1 on no match; # Gather current counts per profile (grep -c prints 0 with exit 1 on no match;
# the || true masks the exit code while preserving the "0" on stdout) # the || true masks the exit code while preserving the "0" on stdout)
get_count() { get_count() {
@@ -29,7 +43,7 @@ PIP_C=$(get_count pip)
N646_C=$(get_count 646) N646_C=$(get_count 646)
OPM_C=$(get_count opm) OPM_C=$(get_count opm)
python3 - "$WATERMARK" "$MUSE_C" "$PIP_C" "$N646_C" "$OPM_C" "$1" <<'PYEOF' python3 - "$WATERMARK" "$MUSE_C" "$PIP_C" "$N646_C" "$OPM_C" "$AS_JSON" "$NO_ADVANCE" <<'PYEOF'
import json, sys, os import json, sys, os
watermark_path = sys.argv[1] watermark_path = sys.argv[1]
@@ -39,7 +53,8 @@ current = {
"646": int(sys.argv[4]), "646": int(sys.argv[4]),
"opm": int(sys.argv[5]), "opm": int(sys.argv[5]),
} }
as_json = len(sys.argv) > 6 and sys.argv[6] == "--json" as_json = len(sys.argv) > 6 and sys.argv[6] == "1"
no_advance = len(sys.argv) > 7 and sys.argv[7] == "1"
# Load watermark (tolerate missing/corrupt file -> treat as all-zero) # Load watermark (tolerate missing/corrupt file -> treat as all-zero)
watermark = {} watermark = {}
@@ -73,9 +88,12 @@ else:
if not any_new: if not any_new:
print("OK: no new errors") print("OK: no new errors")
# Update watermark atomically # Update watermark atomically (skipped in --no-advance peek mode: the
tmp = watermark_path + ".tmp" # caller gets a read-only view and the CLI's advance-on-read semantics are
with open(tmp, "w") as f: # left untouched).
if not no_advance:
tmp = watermark_path + ".tmp"
with open(tmp, "w") as f:
json.dump({"counts": current}, f, indent=2) json.dump({"counts": current}, f, indent=2)
os.replace(tmp, watermark_path) os.replace(tmp, watermark_path)
PYEOF PYEOF
+138 -24
View File
@@ -1,33 +1,52 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Side-chat to main-chat work siphon — detection rules. Side-chat to main-chat work siphon — detection rules (REPAIRED, agent 2 of 5).
Monitors side chat messages and identifies "siphon-worthy" content: Fixes the false-positive ✅ COMPLETED relay at the source:
work that should surface in main chat for visibility. "Sending is disabled until this conversation can be verified." → COMPLETED
was caused by a SINGLE keyword ("verified") matching one regex.
Categories: Repairs (see OUTPUT.md for rationale):
COMPLETED - work finished, results ready 1. COMPLETED requires >= 2 DISTINCT pattern hits (weighted: the structured
BLOCKER - something is stuck, needs intervention `[RESULT ...] OK` marker counts 2 — it is the fleet's own machine-emitted
DECISION - a decision is needed from the user/operator completion signal, far less ambiguous than a bare "done").
ALERT - health/security/urgency signal 2. Negation guards: negation/failure-state words veto COMPLETED outright
MILESTONE - significant progress checkpoint (fail-closed: a negated completion claim is never relayed as complete).
3. Honest labeling: the fake "confidence 60%" (which literally meant "one
regex hit") is replaced by a keyword-hit count. SiphonHit.hits is the
authoritative field; `confidence` is kept for backward compatibility
but must NOT be rendered as a percentage anywhere user-facing.
4. Stale suppression: a message older than 15 minutes never relays as
COMPLETED. Pass message_ts (epoch seconds). monitor.py currently does
NOT pass a timestamp — agent 3 / the integrator must thread
message["ts"] through (see OUTPUT.md).
Detection is purely pattern-based (raw Python, no AI). DO NOT overwrite the original detect.py with this file until the integrator
Each rule returns (category, confidence, summary) or None. reconciles all 5 agents' outputs.
""" """
import re import re
from dataclasses import dataclass import time
from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@dataclass @dataclass
class SiphonHit: class SiphonHit:
category: str # COMPLETED, BLOCKER, DECISION, ALERT, MILESTONE category: str # COMPLETED, BLOCKER, DECISION, ALERT, MILESTONE
confidence: float # 0.0 - 1.0 confidence: float # LEGACY — kept for API compatibility only.
summary: str # one-line summary for main chat # Do NOT render as "confidence NN%"; it is not a
thread_id: str # source side chat # reliability measure. See `hits`.
message_id: str # source message hits: int = 0 # AUTHORITATIVE — distinct keyword-pattern hits
# (weighted; see COMPLETED_PATTERN_WEIGHTS).
summary: str = "" # one-line summary for main chat
thread_id: str = "" # source side chat
message_id: str = "" # source message
author: str = "" # INTEGRATOR (agent 3 absent) — the message's real
# author, plumbed from message["author"] by
# monitor.py. Empty = unknown; NEVER substitute the
# thread's registered agent silently (see
# format_siphon).
# Full message text is NOT stored here — main chat gets a summary # Full message text is NOT stored here — main chat gets a summary
# plus a link back, never the full content (safety: no sensitive # plus a link back, never the full content (safety: no sensitive
# data siphoned verbatim). # data siphoned verbatim).
@@ -42,6 +61,11 @@ COMPLETED_PATTERNS = [
re.compile(r'\b(merged|committed|pushed|published)\b', re.I), re.compile(r'\b(merged|committed|pushed|published)\b', re.I),
] ]
# Weighted hits: the structured [RESULT] OK marker is the fleet's own
# machine-emitted completion signal — unambiguous enough to stand alone.
COMPLETED_PATTERN_WEIGHTS = {0: 1, 1: 1, 2: 2, 3: 1}
COMPLETED_MIN_WEIGHT = 2 # >= 2 distinct pattern hits (or one [RESULT] OK)
BLOCKER_PATTERNS = [ BLOCKER_PATTERNS = [
re.compile(r'\b(blocked|stuck|failing|broken|down|error|failed)\b', re.I), re.compile(r'\b(blocked|stuck|failing|broken|down|error|failed)\b', re.I),
re.compile(r'\b(need|needs|waiting)\s+(your|approval|input|decision)\b', re.I), re.compile(r'\b(need|needs|waiting)\s+(your|approval|input|decision)\b', re.I),
@@ -75,9 +99,29 @@ SUPPRESS_PATTERNS = [
re.compile(r'\[do not siphon\]', re.I), # explicit opt-out marker re.compile(r'\[do not siphon\]', re.I), # explicit opt-out marker
] ]
# --- Negation guards: any match vetoes COMPLETED (fail-closed) ---
# A completion claim in the presence of negation / failure-state language
# is never relayed as ✅ COMPLETED, no matter how many keywords hit.
NEGATION_GUARDS = [
# explicit negation
re.compile(r'\b(not|never|no|nothing|none|neither|nor)\b', re.I),
re.compile(r"\b(do not|don't|didn't|doesn't|won't|can't|cannot|isn't|aren't|"
r"wasn't|weren't|haven't|hasn't|hadn't|couldn't|shouldn't)\b", re.I),
# incompleteness hedges
re.compile(r'\b(still|yet|pending|unfinished|incomplete)\b', re.I),
# failure-state words (a "completed" message containing these is suspect)
re.compile(r'\b(broken|failed|failing|failure|down|stuck|blocked|disabled|'
r'error|errors|crash|crashed)\b', re.I),
# hedging conjunctions ("deployed, but tests are red")
re.compile(r'\b(but|however|although|though)\b', re.I),
]
# Messages older than this never relay as COMPLETED (seconds).
COMPLETED_MAX_AGE_S = 15 * 60
def _match_score(text: str, patterns) -> float: def _match_score(text: str, patterns) -> float:
"""Return confidence based on how many patterns match.""" """Legacy confidence for non-COMPLETED categories (unchanged)."""
hits = sum(1 for p in patterns if p.search(text)) hits = sum(1 for p in patterns if p.search(text))
if hits == 0: if hits == 0:
return 0.0 return 0.0
@@ -85,6 +129,21 @@ def _match_score(text: str, patterns) -> float:
return min(0.95, 0.6 + (hits - 1) * 0.2) return min(0.95, 0.6 + (hits - 1) * 0.2)
def _completed_weight(text: str):
"""
Return (weighted_hits, distinct_hits, matched_pattern_indexes) for
COMPLETED_PATTERNS. Weighted: [RESULT] OK counts 2.
"""
matched = [i for i, p in enumerate(COMPLETED_PATTERNS) if p.search(text)]
weight = sum(COMPLETED_PATTERN_WEIGHTS.get(i, 1) for i in matched)
return weight, len(matched), matched
def _is_negated(text: str) -> bool:
"""True if any negation guard fires anywhere in the text."""
return any(p.search(text) for p in NEGATION_GUARDS)
def _extract_summary(text: str, max_len: int = 120) -> str: def _extract_summary(text: str, max_len: int = 120) -> str:
"""Extract a safe one-line summary. Strips to first meaningful line.""" """Extract a safe one-line summary. Strips to first meaningful line."""
# Take first non-empty line, truncate # Take first non-empty line, truncate
@@ -98,28 +157,58 @@ def _extract_summary(text: str, max_len: int = 120) -> str:
def detect(text: str, thread_id: str, message_id: str, def detect(text: str, thread_id: str, message_id: str,
min_confidence: float = 0.6) -> Optional[SiphonHit]: min_confidence: float = 0.6,
message_ts: Optional[float] = None) -> Optional[SiphonHit]:
""" """
Check a side chat message for siphon-worthy content. Check a side chat message for siphon-worthy content.
Returns SiphonHit or None. Returns SiphonHit or None.
message_ts: epoch seconds of the original message (optional). Messages
older than COMPLETED_MAX_AGE_S (15 min) never relay as COMPLETED.
NOTE: monitor.py does not currently pass a timestamp — agent 3 / the
integrator must thread message["ts"] through the detect() call.
""" """
# Safety: suppress sensitive content # Safety: suppress sensitive content
for p in SUPPRESS_PATTERNS: for p in SUPPRESS_PATTERNS:
if p.search(text): if p.search(text):
return None return None
# Stale suppression applies to COMPLETED only.
completed_allowed = True
if message_ts is not None:
try:
age = time.time() - float(message_ts)
if age > COMPLETED_MAX_AGE_S:
completed_allowed = False
except (TypeError, ValueError):
pass # unparseable ts: proceed, do not fail closed on metadata
# COMPLETED: >=2 distinct weighted pattern hits, no negation, not stale.
completed_hits = 0
completed_conf = 0.0
if completed_allowed and not _is_negated(text):
weight, distinct, _ = _completed_weight(text)
if weight >= COMPLETED_MIN_WEIGHT:
completed_hits = weight
# legacy confidence kept for API compat; NOT a reliability measure
completed_conf = min(0.95, 0.6 + (distinct - 1) * 0.2)
candidates = [ candidates = [
("COMPLETED", _match_score(text, COMPLETED_PATTERNS)), ("COMPLETED", completed_conf, completed_hits),
("BLOCKER", _match_score(text, BLOCKER_PATTERNS)), ("BLOCKER", _match_score(text, BLOCKER_PATTERNS),
("DECISION", _match_score(text, DECISION_PATTERNS)), sum(1 for p in BLOCKER_PATTERNS if p.search(text))),
("ALERT", _match_score(text, ALERT_PATTERNS)), ("DECISION", _match_score(text, DECISION_PATTERNS),
("MILESTONE", _match_score(text, MILESTONE_PATTERNS)), sum(1 for p in DECISION_PATTERNS if p.search(text))),
("ALERT", _match_score(text, ALERT_PATTERNS),
sum(1 for p in ALERT_PATTERNS if p.search(text))),
("MILESTONE", _match_score(text, MILESTONE_PATTERNS),
sum(1 for p in MILESTONE_PATTERNS if p.search(text))),
] ]
# Sort by confidence descending; ALERT wins ties (safety: urgency first) # Sort by confidence descending; ALERT wins ties (safety: urgency first)
# Use negative confidence for descending, and ALERT as tiebreaker # Use negative confidence for descending, and ALERT as tiebreaker
candidates.sort(key=lambda x: (-x[1], 0 if x[0] == "ALERT" else 1)) candidates.sort(key=lambda x: (-x[1], 0 if x[0] == "ALERT" else 1))
best_cat, best_conf = candidates[0] best_cat, best_conf, best_hits = candidates[0]
if best_conf < min_confidence: if best_conf < min_confidence:
return None return None
@@ -127,12 +216,37 @@ def detect(text: str, thread_id: str, message_id: str,
return SiphonHit( return SiphonHit(
category=best_cat, category=best_cat,
confidence=best_conf, confidence=best_conf,
hits=best_hits,
summary=_extract_summary(text), summary=_extract_summary(text),
thread_id=thread_id, thread_id=thread_id,
message_id=message_id, message_id=message_id,
) )
def format_siphon(hit: SiphonHit, agent_name: str = "sidechat") -> str:
"""
Format a siphon message for main chat.
HONEST LABELING: reports keyword hit count, never a fake "confidence %".
HONEST AUTHORSHIP (integrator, agent 3 absent): attributes the message's
real author when known. Falls back to the thread's registered agent only
when the author is unknown — and says so explicitly, so a relay can
never again launder thread ownership as authorship.
"""
emoji = {"COMPLETED": "✅", "BLOCKER": "🚧", "DECISION": "❓",
"ALERT": "🚨", "MILESTONE": "🎯"}.get(hit.category, "📋")
thread_url = f"https://muse.ai/thread/{hit.thread_id}"
if hit.author:
attribution = f"from {hit.author}"
else:
attribution = f"from {agent_name} side chat (author unverified)"
return (
f"{emoji} [{hit.category}] {attribution}\n"
f"{hit.summary}\n"
f"→ {thread_url}\n"
f"(keyword hits: {hit.hits})"
)
# --- Opt-out registry --- # --- Opt-out registry ---
_opt_out_threads: set = set() _opt_out_threads: set = set()
+9 -2
View File
@@ -19,6 +19,9 @@
# FLEET_ALERT_DRY_RUN=1 evaluate + print, write no state/outbox, no notify # FLEET_ALERT_DRY_RUN=1 evaluate + print, write no state/outbox, no notify
# FLEET_ALERT_INJECT_FAIL= test hook: comma-separated condition ids to force-fail # FLEET_ALERT_INJECT_FAIL= test hook: comma-separated condition ids to force-fail
# (e.g. FLEET_ALERT_INJECT_FAIL=cdp:pip) # (e.g. FLEET_ALERT_INJECT_FAIL=cdp:pip)
# FLEET_BL_RELAY=1 re-enable the bl-side #lobby relay (default 0/off:
# the container-side hook is the live pager; running
# both double-posts every alert — 2026-10-06)
# #
# State: ~/.local/share/fleet-alert/state.json (per-condition consecutive counters) # State: ~/.local/share/fleet-alert/state.json (per-condition consecutive counters)
# Outbox: ~/.local/share/fleet-alert/outbox.jsonl (ALERT/RECOVERY records for the relay) # Outbox: ~/.local/share/fleet-alert/outbox.jsonl (ALERT/RECOVERY records for the relay)
@@ -435,7 +438,11 @@ rm -f "$STATE_DIR/.alerts.tmp"
tail -500 "$LOG" > "$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG" tail -500 "$LOG" > "$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG"
log "check complete" log "check complete"
# Relay pending outbox records to #lobby with idempotency gates (posted watermark + content hash TTL) # Bl-side #lobby relay: DISABLED by default (FLEET_BL_RELAY=1 to re-enable).
if [ "$DRY_RUN" -eq 0 ] && [ -x "$BIN/fleet-alert-relay.sh" ]; then # The container-side hook is the live pager; the bl relay never successfully
# posted (missing CHAT_KEYFILE) and enabling it now would double-post every
# alert in a second format. Re-enable only alongside retiring the container
# hook (and per the relay header, with opm sign-off).
if [ "${FLEET_BL_RELAY:-0}" = "1" ] && [ "$DRY_RUN" -eq 0 ] && [ -x "$BIN/fleet-alert-relay.sh" ]; then
"$BIN/fleet-alert-relay.sh" >> "$LOG" 2>&1 || true "$BIN/fleet-alert-relay.sh" >> "$LOG" 2>&1 || true
fi fi
+25 -2
View File
@@ -5,6 +5,10 @@
# branch; deployment needs opm review + sign-off. See # branch; deployment needs opm review + sign-off. See
# docs/FLEET-ALERT-DUP-POST-GATE.md. # docs/FLEET-ALERT-DUP-POST-GATE.md.
# #
# NOTE (2026-10-06): auto-invoke from fleet-alert-check.sh is disabled by
# default (FLEET_BL_RELAY=1 re-enables). The container-side hook pages
# #lobby today; do not re-enable without retiring it first.
#
# The 2026-10-05 11:28Z incident: one RECOVERY record in the outbox became two # The 2026-10-05 11:28Z incident: one RECOVERY record in the outbox became two
# identical verified #lobby posts (seq 642/643, 3.35s apart) because the relay # identical verified #lobby posts (seq 642/643, 3.35s apart) because the relay
# leg had no idempotency: append-only outbox, no consume tracking, no content # leg had no idempotency: append-only outbox, no consume tracking, no content
@@ -68,7 +72,7 @@ transport_post() { # $1 = text
local text="$1" ts sig payload resp http local text="$1" ts sig payload resp http
ts="$(date +%s)" ts="$(date +%s)"
if ! sig="$(sign_payload "$(printf '%s\n%s\n%s' "$ts" "$CHANNEL" "$text")")"; then if ! sig="$(sign_payload "$(printf '%s\n%s\n%s' "$ts" "$CHANNEL" "$text")")"; then
echo "UNKNOWN sign-failed"; return 0 echo "UNKNOWN sign-failed($KEYFILE)"; return 0
fi fi
payload="$(MSG="$text" TS="$ts" SIG="$sig" python3 -c ' payload="$(MSG="$text" TS="$ts" SIG="$sig" python3 -c '
import json,os import json,os
@@ -263,12 +267,31 @@ main() {
# NOTE: transport_post is invoked via command substitution (subshell), so the # NOTE: transport_post is invoked via command substitution (subshell), so the
# stub counts calls with a file, not a variable. # stub counts calls with a file, not a variable.
self_test() { self_test() {
local td calls lobby ok=1 n local td calls lobby ok=1 n sk sig_out old_key
td="$(mktemp -d)"; export FLEET_ALERT_DIR="$td" td="$(mktemp -d)"; export FLEET_ALERT_DIR="$td"
ALERT_DIR="$td"; OUTBOX="$td/outbox.jsonl"; POSTED="$td/posted.log" ALERT_DIR="$td"; OUTBOX="$td/outbox.jsonl"; POSTED="$td/posted.log"
SEEN="$td/seen-hashes.log"; LOCKF="$td/relay.lock" SEEN="$td/seen-hashes.log"; LOCKF="$td/relay.lock"
calls="$td/calls.log"; lobby="$td/lobby.log" calls="$td/calls.log"; lobby="$td/lobby.log"
touch "$calls" "$lobby" touch "$calls" "$lobby"
# sign_payload must round-trip with a valid key and fail cleanly without
# one (2026-10-06: missing ~/.ssh/id_frontdoor broke every #lobby post
# with an undiagnosable bare "sign-failed").
old_key="$KEYFILE"
sk="$td/signkey"
ssh-keygen -t ed25519 -f "$sk" -N '' -q >/dev/null 2>&1 \
|| { echo "FAIL: cannot generate ephemeral test key"; ok=0; }
if KEYFILE="$sk" sig_out="$(sign_payload "self-test")"; then
case "$sig_out" in
*"BEGIN SSH SIGNATURE"*) : ;;
*) echo "FAIL: sign_payload output not armored"; ok=0 ;;
esac
else
echo "FAIL: sign_payload failed with a valid key"; ok=0
fi
if KEYFILE="$td/no-such-key" sign_payload "self-test" >/dev/null 2>&1; then
echo "FAIL: sign_payload succeeded with a missing key"; ok=0
fi
KEYFILE="$old_key"
# Two identical submissions: same text, different record ids (the 11:28Z shape) # Two identical submissions: same text, different record ids (the 11:28Z shape)
printf '%s\n' \ printf '%s\n' \
'{"id":"rec-A","ts":1791199616,"kind":"RECOVERY","condition":"partition:def"}' \ '{"id":"rec-A","ts":1791199616,"kind":"RECOVERY","condition":"partition:def"}' \
+28 -13
View File
@@ -168,14 +168,32 @@ def _eval(ws, js, timeout=8.0):
return None return None
VERIFY_TRIES = 10
VERIFY_PAUSE = 1.5
def _poll(check, tries=VERIFY_TRIES, pause=VERIFY_PAUSE):
"""Poll a state check until true. Fast exit; tolerates slow commits."""
for _ in range(tries):
try:
if check():
return True
except Exception:
pass
time.sleep(pause)
return False
def list_radios(ws): def list_radios(ws):
"""All dialog radios with heading/label/value/checked (or []).""" """All dialog radios with heading/label/value/checked (or [])."""
return _eval(ws, JS_LIST_RADIOS) or [] rows = _eval(ws, JS_LIST_RADIOS)
return rows if isinstance(rows, list) else []
def list_switches(ws): def list_switches(ws):
"""All dialog switches with row label + checked (or []).""" """All dialog switches with row label + checked (or [])."""
return _eval(ws, JS_LIST_SWITCHES) or [] rows = _eval(ws, JS_LIST_SWITCHES)
return rows if isinstance(rows, list) else []
def radio_state(ws, heading, value): def radio_state(ws, heading, value):
@@ -188,9 +206,9 @@ def radio_state(ws, heading, value):
def set_radio_by_heading(ws, heading, value): def set_radio_by_heading(ws, heading, value):
"""Set a heading-grouped radio; verify, else trusted click, verify.""" """Set a heading-grouped radio; poll, else trusted click, poll."""
if _eval(ws, JS_CLICK_RADIO % (heading, value)) == "CLICKED" \ if _eval(ws, JS_CLICK_RADIO % (heading, value)) == "CLICKED" \
and radio_state(ws, heading, value) is True: and _poll(lambda: radio_state(ws, heading, value) is True):
return True return True
rect = _eval(ws, JS_RADIO_RECT % (heading, value)) rect = _eval(ws, JS_RADIO_RECT % (heading, value))
if not rect or "x" not in rect: if not rect or "x" not in rect:
@@ -199,8 +217,7 @@ def set_radio_by_heading(ws, heading, value):
real_click(ws, rect["x"], rect["y"]) real_click(ws, rect["x"], rect["y"])
except Exception: except Exception:
return False return False
time.sleep(0.6) return _poll(lambda: radio_state(ws, heading, value) is True)
return radio_state(ws, heading, value) is True
def radio_aria_state(ws, name): def radio_aria_state(ws, name):
@@ -214,9 +231,9 @@ def radio_aria_state(ws, name):
def set_radio_by_aria(ws, name): def set_radio_by_aria(ws, name):
"""Set an aria-labeled radio; verify, else trusted click, verify.""" """Set an aria-labeled radio; poll, else trusted click, poll."""
if _eval(ws, JS_CLICK_RADIO_ARIA % name) == "CLICKED" \ if _eval(ws, JS_CLICK_RADIO_ARIA % name) == "CLICKED" \
and radio_aria_state(ws, name) is True: and _poll(lambda: radio_aria_state(ws, name) is True):
return True return True
rect = _eval(ws, JS_RADIO_ARIA_RECT % name) rect = _eval(ws, JS_RADIO_ARIA_RECT % name)
if not rect or "x" not in rect: if not rect or "x" not in rect:
@@ -225,8 +242,7 @@ def set_radio_by_aria(ws, name):
real_click(ws, rect["x"], rect["y"]) real_click(ws, rect["x"], rect["y"])
except Exception: except Exception:
return False return False
time.sleep(0.6) return _poll(lambda: radio_aria_state(ws, name) is True)
return radio_aria_state(ws, name) is True
def switch_state(ws, label): def switch_state(ws, label):
@@ -247,7 +263,7 @@ def set_switch(ws, label, on):
if state == bool(on): if state == bool(on):
return True return True
if _eval(ws, JS_CLICK_SWITCH % label) == "CLICKED" \ if _eval(ws, JS_CLICK_SWITCH % label) == "CLICKED" \
and switch_state(ws, label) is bool(on): and _poll(lambda: switch_state(ws, label) is bool(on)):
return True return True
rect = _eval(ws, JS_SWITCH_RECT % label) rect = _eval(ws, JS_SWITCH_RECT % label)
if not rect or "x" not in rect: if not rect or "x" not in rect:
@@ -256,5 +272,4 @@ def set_switch(ws, label, on):
real_click(ws, rect["x"], rect["y"]) real_click(ws, rect["x"], rect["y"])
except Exception: except Exception:
return False return False
time.sleep(0.6) return _poll(lambda: switch_state(ws, label) is bool(on))
return switch_state(ws, label) is bool(on)
+15 -4
View File
@@ -6,6 +6,7 @@ idempotent (re-clicking the active tab is a harmless no-op), so goto
always clicks and reports the click result instead of guessing which always clicks and reports the click result instead of guessing which
tab is active. tab is active.
""" """
import json
import time import time
from approvals import cdp_evaluate from approvals import cdp_evaluate
@@ -16,6 +17,10 @@ TAB_NAMES = ["General", "Connectors", "Wallet", "Secure store",
"Data controls", "Help & support", "Legal info"] "Data controls", "Help & support", "Legal info"]
TABS = TAB_NAMES # legacy alias TABS = TAB_NAMES # legacy alias
# Tab rail buttons carry bare tab names and live outside any nav
# landmark, so row matchers exclude them by exact text (live 2026-10-06).
_JS_TABS = json.dumps(TAB_NAMES)
DOCK_MORE_TESTID = "hatch-dock-more" DOCK_MORE_TESTID = "hatch-dock-more"
JS_DOCK_RECT = ("(() => { const b = document.querySelector(" JS_DOCK_RECT = ("(() => { const b = document.querySelector("
@@ -41,21 +46,26 @@ JS_GOTO_TAB_TMPL = ("(() => { const b = Array.from(document."
JS_CLICK_ROW_TMPL = ("((name) => {" JS_CLICK_ROW_TMPL = ("((name) => {"
" const d = document.querySelector('[role=\"dialog\"]');" " const d = document.querySelector('[role=\"dialog\"]');"
" if (!d) return 'NO_DIALOG';" " if (!d) return 'NO_DIALOG';"
" const TABS = " + _JS_TABS + ";"
" const inNav = (el) => !!el.closest(" " const inNav = (el) => !!el.closest("
"'nav, [role=\"tablist\"], [role=\"navigation\"]');" "'nav, [role=\"tablist\"], [role=\"navigation\"]');"
" const els = Array.from(d.querySelectorAll(" " const els = Array.from(d.querySelectorAll("
"'button, [role=\"button\"], a')).filter(e => !inNav(e));" "'button, [role=\"button\"], a')).filter(e => !inNav(e));"
" const t = els.find(e => (e.innerText || '').trim()" " const t = els.find(e => {"
".toLowerCase().startsWith(name.toLowerCase()));" " const txt = (e.innerText || '').trim();"
" return !TABS.includes(txt) && txt.toLowerCase()"
".startsWith(name.toLowerCase()); });"
" if (!t) return 'NO_ROW'; t.click(); return 'CLICKED'; })('%s')") " if (!t) return 'NO_ROW'; t.click(); return 'CLICKED'; })('%s')")
JS_DESCRIBE_ROWS = ("(() => {" JS_DESCRIBE_ROWS = ("(() => {"
" const d = document.querySelector('[role=\"dialog\"]');" " const d = document.querySelector('[role=\"dialog\"]');"
" if (!d) return null;" " if (!d) return null;"
" const TABS = " + _JS_TABS + ";"
" const inNav = (el) => !!el.closest(" " const inNav = (el) => !!el.closest("
"'nav, [role=\"tablist\"], [role=\"navigation\"]');" "'nav, [role=\"tablist\"], [role=\"navigation\"]');"
" return Array.from(d.querySelectorAll(" " return Array.from(d.querySelectorAll("
"'button, [role=\"button\"], a')).filter(e => !inNav(e))" "'button, [role=\"button\"], a')).filter(e => !inNav(e))"
".filter(e => !TABS.includes((e.innerText || '').trim()))"
".map(e => { const lines = (e.innerText || '').trim().split('\\n');" ".map(e => { const lines = (e.innerText || '').trim().split('\\n');"
" return {name: (lines[0] || '').slice(0, 80)," " return {name: (lines[0] || '').slice(0, 80),"
" detail: lines.slice(1).join(' / ').slice(0, 120)}; }); })()") " detail: lines.slice(1).join(' / ').slice(0, 120)}; }); })()")
@@ -85,7 +95,7 @@ def dialog_text(ws, timeout=8.0, limit=4000):
text = cdp_evaluate(ws, JS_DIALOG_TEXT, timeout=timeout) text = cdp_evaluate(ws, JS_DIALOG_TEXT, timeout=timeout)
except Exception: except Exception:
return None return None
if not text: if not isinstance(text, str) or not text:
return None return None
return text[:limit] return text[:limit]
@@ -135,7 +145,8 @@ def describe_rows(ws, tab=None, timeout=8.0):
"""Inventory rows (name/detail) on a tab. [] when unreadable.""" """Inventory rows (name/detail) on a tab. [] when unreadable."""
if tab is not None and not goto_tab(ws, tab, timeout=timeout): if tab is not None and not goto_tab(ws, tab, timeout=timeout):
return [] return []
return _eval(ws, JS_DESCRIBE_ROWS, timeout=timeout) or [] rows = _eval(ws, JS_DESCRIBE_ROWS, timeout=timeout)
return rows if isinstance(rows, list) else []
def go_back(ws): def go_back(ws):
+9 -3
View File
@@ -1,12 +1,13 @@
"""Data controls tab: model-improvement switch (read-only otherwise). """Data controls tab: model-improvement switch (read-only otherwise).
The switch row label is not yet pinned from recon, so resolution The switch label is pinned from live recon; resolution prefers it
prefers the single switch on the tab and falls back to keyword and falls back to single-switch, then keyword match. Import/Delete
match. Import/Delete rows are inventoried, never touched. rows are inventoried, never touched.
""" """
from hatch_menu import controls, dialog from hatch_menu import controls, dialog
TAB = "Data controls" TAB = "Data controls"
SWITCH_LABEL = "Help improve our AI models"
_KEYWORDS = ("improv", "train", "model", "data", "usage") _KEYWORDS = ("improv", "train", "model", "data", "usage")
@@ -15,6 +16,11 @@ def _resolve(ws):
if not dialog.goto_tab(ws, TAB): if not dialog.goto_tab(ws, TAB):
return None return None
switches = controls.list_switches(ws) switches = controls.list_switches(ws)
for s in switches:
blob = ((s.get("label") or "") + " "
+ (s.get("aria") or "")).lower()
if SWITCH_LABEL.lower() in blob:
return s
if len(switches) == 1: if len(switches) == 1:
return switches[0] return switches[0]
for kw in _KEYWORDS: for kw in _KEYWORDS:
+1 -1
View File
@@ -10,7 +10,7 @@ from hatch_menu import controls, dialog
TAB = "General" TAB = "General"
THEME_VALUES = ("match", "default", "blue", "purple", "pink", THEME_VALUES = ("avatar", "default", "blue", "purple", "pink",
"orange", "green", "beige", "monochrome") "orange", "green", "beige", "monochrome")
_FREE_RE = re.compile(r"\bfree plan\b", re.IGNORECASE) _FREE_RE = re.compile(r"\bfree plan\b", re.IGNORECASE)
+110 -71
View File
@@ -25,7 +25,18 @@ ADV_LABELS = {"transparent_proxy": "Transparent proxy",
"tls_interception": "TLS interception", "tls_interception": "TLS interception",
"sni_mismatch_rejection": "SNI mismatch rejection"} "sni_mismatch_rejection": "SNI mismatch rejection"}
PROTOCOL_SLUGS = {"Model Context Protocol servers (SSE)": "mcp-sse", # Row titles (first line of each protocol row) pinned live 2026-10-06:
# network primitives on every node checked; MCP titles kept
# defensively in case they appear on other plans/accounts.
PROTOCOL_SLUGS = {"Outbound SSH": "outbound-ssh",
"Outgoing email (SMTP)": "smtp",
"Email mailbox access (IMAP, POP3)": "imap-pop3",
"Database connections": "database",
"File transfer (FTP)": "ftp",
"External DNS lookups": "dns",
"Other TCP connections": "other-tcp",
"Other UDP traffic": "other-udp",
"Model Context Protocol servers (SSE)": "mcp-sse",
"Model Context Protocol servers (Streamable HTTP)": "Model Context Protocol servers (Streamable HTTP)":
"mcp-streamable", "mcp-streamable",
"Agent Skills endpoints": "agent-skills", "Agent Skills endpoints": "agent-skills",
@@ -35,20 +46,16 @@ PROTOCOL_SLUGS = {"Model Context Protocol servers (SSE)": "mcp-sse",
JS_WEBSITES = """(() => { JS_WEBSITES = """(() => {
const d = document.querySelector('[role="dialog"]'); const d = document.querySelector('[role="dialog"]');
if (!d) return null; if (!d) return null;
return Array.from(d.querySelectorAll('button')).filter(b => const out = [];
['allow', 'ask', 'deny'].includes((b.getAttribute('aria-label') || '') for (const b of d.querySelectorAll('button')) {
.trim().toLowerCase())).map(b => { const m = (b.getAttribute('aria-label') || '').match(
let el = b.parentElement, host = '', depth = 0; /^Change permission mode for (.+),\\s*(Allow|Ask|Deny)$/i);
while (el && el !== d && depth < 6) { if (!m) continue;
const t = (el.innerText || '').trim().split('\\n')[0] || ''; const r = b.getBoundingClientRect();
if (t && t.includes('.') && t.length < 120) { host = t; break; } out.push({host: m[1].trim(), mode: m[2],
el = el.parentElement; x: r.x + r.width / 2, y: r.y + r.height / 2});
depth += 1;
} }
return {host: host, mode: (b.getAttribute('aria-label') || '').trim(), return out;
x: b.getBoundingClientRect().x + b.getBoundingClientRect().width / 2,
y: b.getBoundingClientRect().y + b.getBoundingClientRect().height / 2};
});
})()""" })()"""
JS_MODE_MENU = """(() => { JS_MODE_MENU = """(() => {
@@ -64,27 +71,19 @@ JS_CLICK_MODE = """((mode) => {
return 'CLICKED'; return 'CLICKED';
})('%s')""" })('%s')"""
JS_MODE_RECT = """((mode) => {
const m = Array.from(document.querySelectorAll('[role="menuitem"]'))
.find(el => (el.innerText || '').trim() === mode);
if (!m) return null;
const r = m.getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
})('%s')"""
JS_PROTO_ROWS = """(() => { JS_PROTO_ROWS = """(() => {
const d = document.querySelector('[role="dialog"]'); const d = document.querySelector('[role="dialog"]');
if (!d) return null; if (!d) return null;
return Array.from(d.querySelectorAll('[role="switch"]')).map(s => { return Array.from(d.querySelectorAll('[role="switch"]')).map(s => {
let el = s.parentElement, label = '', depth = 0; let el = s.parentElement, title = '', depth = 0;
while (el && el !== d && depth < 6) { while (el && el !== d && depth < 6) {
const t = (el.innerText || '').trim().replace(/\\s+/g, ' '); const t = (el.innerText || '').trim().split('\\n')[0] || '';
if (t && t.length < 250) { label = t; break; } if (t) { title = t.slice(0, 80); break; }
el = el.parentElement; el = el.parentElement;
depth += 1; depth += 1;
} }
const r = s.getBoundingClientRect(); const r = s.getBoundingClientRect();
return {label: label.slice(0, 120), return {title: title,
checked: s.getAttribute('aria-checked') === 'true', checked: s.getAttribute('aria-checked') === 'true',
x: r.x + r.width / 2, y: r.y + r.height / 2}; x: r.x + r.width / 2, y: r.y + r.height / 2};
}); });
@@ -98,23 +97,55 @@ def _eval(ws, js, timeout=8.0):
return None return None
def _slug(label): def _stable_rows(ws, js, retries=4, pause=1.5):
"""Repeat a row read until two consecutive reads agree.
Guards mid-animation partial DOM (innerText shifts while the
sub-page slides in). Returns the agreed list, or None.
"""
last = "sentinel"
for _ in range(retries):
rows = _eval(ws, js)
if isinstance(rows, list) and rows == last:
return rows
last = rows if isinstance(rows, list) else "sentinel"
time.sleep(pause)
return last if isinstance(last, list) else None
def _slug(title):
"""Protocol slug: registry hit, else slugified, else None.""" """Protocol slug: registry hit, else slugified, else None."""
if label in PROTOCOL_SLUGS: if title in PROTOCOL_SLUGS:
return PROTOCOL_SLUGS[label] return PROTOCOL_SLUGS[title]
clean = re.sub(r"[^a-z0-9]+", "-", clean = re.sub(r"[^a-z0-9]+", "-",
label.strip().lower()).strip("-") title.strip().lower()).strip("-")
return clean or None return clean or None
def _canon_mode(mode):
"""Canonical Allow/Ask/Deny (case-insensitive); passthrough else."""
for m in WEBSITE_MODES:
if (mode or "").lower() == m.lower():
return m
return mode
def resolve_protocol(name): def resolve_protocol(name):
"""Slug or label fragment -> row label, None when unresolvable.""" """Slug/title -> row title, None when unresolvable.
Exact slug or title first; then a unique case-insensitive
substring over titles+slugs (so 'ssh' finds Outbound SSH).
"""
if not isinstance(name, str) or not name.strip(): if not isinstance(name, str) or not name.strip():
return None return None
want = name.strip().lower() want = name.strip().lower()
for label, slug in PROTOCOL_SLUGS.items(): for title, slug in PROTOCOL_SLUGS.items():
if want == slug or want == label.lower(): if want == slug or want == title.lower():
return label return title
hits = [t for t, s in PROTOCOL_SLUGS.items()
if want in t.lower() or want in s]
if len(hits) == 1:
return hits[0]
return None return None
@@ -195,8 +226,7 @@ def _websites_raw(ws):
"""Drill into Websites; rows or None (stays on sub-page).""" """Drill into Websites; rows or None (stays on sub-page)."""
if not dialog.click_row(ws, "Websites", TAB): if not dialog.click_row(ws, "Websites", TAB):
return None return None
time.sleep(0.6) return _stable_rows(ws, JS_WEBSITES)
return _eval(ws, JS_WEBSITES)
def websites(ws): def websites(ws):
@@ -204,7 +234,8 @@ def websites(ws):
rows = _websites_raw(ws) rows = _websites_raw(ws)
if rows is None: if rows is None:
return [] return []
out = [{"host": r.get("host"), "mode": r.get("mode")} for r in rows] out = [{"host": r.get("host"), "mode": _canon_mode(r.get("mode"))}
for r in rows]
_back_to_root(ws) _back_to_root(ws)
return out return out
@@ -218,7 +249,12 @@ def website_mode(ws, host):
def set_website_mode(ws, host, mode): def set_website_mode(ws, host, mode):
"""Set one host mode via the mode chooser. Verify + readback. Bool.""" """Set one host mode via the mode chooser. Bool.
One-way for Ask/Deny: the override row leaves the allowed list
(no add UI), so removal verifies by absence. No-op when already
there; absent hosts fail (nothing to click).
"""
if mode not in WEBSITE_MODES: if mode not in WEBSITE_MODES:
return False return False
rows = _websites_raw(ws) rows = _websites_raw(ws)
@@ -230,14 +266,18 @@ def set_website_mode(ws, host, mode):
if target is None: if target is None:
_back_to_root(ws) _back_to_root(ws)
return False return False
if _canon_mode(target.get("mode")) == mode:
_back_to_root(ws)
return True
try: try:
real_click(ws, target["x"], target["y"]) real_click(ws, target["x"], target["y"])
except Exception: except Exception:
_back_to_root(ws) _back_to_root(ws)
return False return False
time.sleep(0.8) time.sleep(0.8)
items = _eval(ws, JS_MODE_MENU) or [] items = _eval(ws, JS_MODE_MENU)
texts = [(i.get("text") or "") for i in items] texts = [(i.get("text") or "") for i in items] \
if isinstance(items, list) else []
if mode not in texts: if mode not in texts:
escape(ws) escape(ws)
_back_to_root(ws) _back_to_root(ws)
@@ -246,26 +286,19 @@ def set_website_mode(ws, host, mode):
escape(ws) escape(ws)
_back_to_root(ws) _back_to_root(ws)
return False return False
time.sleep(0.6) for _ in range(5):
rows = _eval(ws, JS_WEBSITES) or [] time.sleep(2.0)
cur = next(((r.get("mode")) for r in rows rows = _eval(ws, JS_WEBSITES)
if not isinstance(rows, list):
continue
cur = next((_canon_mode(r.get("mode")) for r in rows
if (r.get("host") or "").lower() == host.lower()), if (r.get("host") or "").lower() == host.lower()),
None) None)
if cur == mode: if mode in ("Ask", "Deny"):
if cur is None:
_back_to_root(ws) _back_to_root(ws)
return True return True
rect = _eval(ws, JS_MODE_RECT % mode) elif cur == mode:
if rect and "x" in rect:
try:
real_click(ws, rect["x"], rect["y"])
except Exception:
pass
time.sleep(0.6)
rows = _eval(ws, JS_WEBSITES) or []
cur = next(((r.get("mode")) for r in rows
if (r.get("host") or "").lower() == host.lower()),
None)
if cur == mode:
_back_to_root(ws) _back_to_root(ws)
return True return True
escape(ws) escape(ws)
@@ -277,36 +310,35 @@ def _protocols_raw(ws):
"""Drill into protocols; rows or None (stays on sub-page).""" """Drill into protocols; rows or None (stays on sub-page)."""
if not dialog.click_row(ws, "Direct network protocols", TAB): if not dialog.click_row(ws, "Direct network protocols", TAB):
return None return None
time.sleep(0.6) return _stable_rows(ws, JS_PROTO_ROWS)
return _eval(ws, JS_PROTO_ROWS)
def protocols(ws): def protocols(ws):
"""[{slug, label, on}] (back at root afterwards).""" """[{slug, title, on}] (back at root afterwards)."""
rows = _protocols_raw(ws) rows = _protocols_raw(ws)
if rows is None: if rows is None:
return [] return []
out = [{"slug": _slug(r.get("label", "")), out = [{"slug": _slug(r.get("title", "")),
"label": r.get("label", ""), "title": r.get("title", ""),
"on": "on" if r.get("checked") else "off"} for r in rows] "on": "on" if r.get("checked") else "off"} for r in rows]
_back_to_root(ws) _back_to_root(ws)
return out return out
def protocol_state(ws, label): def protocol_state(ws, title):
"""on/off for one protocol row label, None when absent.""" """on/off for one protocol row title, None when absent."""
for row in protocols(ws): for row in protocols(ws):
if row.get("label") == label: if row.get("title") == title:
return row.get("on") return row.get("on")
return None return None
def set_protocol(ws, label, on): def set_protocol(ws, title, on):
"""Set one protocol switch in place; readback before returning.""" """Set one protocol switch in place; readback before returning."""
rows = _protocols_raw(ws) rows = _protocols_raw(ws)
if rows is None: if rows is None:
return False return False
target = next((r for r in rows if r.get("label") == label), None) target = next((r for r in rows if r.get("title") == title), None)
if target is None: if target is None:
_back_to_root(ws) _back_to_root(ws)
return False return False
@@ -319,12 +351,19 @@ def set_protocol(ws, label, on):
except Exception: except Exception:
_back_to_root(ws) _back_to_root(ws)
return False return False
time.sleep(0.6) for _ in range(8):
rows = _eval(ws, JS_PROTO_ROWS) or [] time.sleep(2.0)
cur = next((r for r in rows if r.get("label") == label), None) rows = _eval(ws, JS_PROTO_ROWS)
ok = cur is not None and bool(cur.get("checked")) == want if not isinstance(rows, list):
continue
cur = next((r for r in rows if r.get("title") == title), None)
if cur is not None and bool(cur.get("checked")) == want:
_back_to_root(ws) _back_to_root(ws)
return ok return True
_back_to_root(ws)
# In-dialog verify missed (slow commit or commit-on-close); the
# toggles-level fresh readback is the source of truth.
return False
def manage_counts(ws): def manage_counts(ws):
+13 -5
View File
@@ -182,6 +182,10 @@ def get_toggle(node, name):
else: else:
value = None value = None
if value is None: if value is None:
if kind == "website":
return {"ok": False, "node": node, "toggle": name,
"error": "host not in Websites list (effective: "
"permissions.web_access default)"}
return {"ok": False, "node": node, "toggle": name, return {"ok": False, "node": node, "toggle": name,
"error": "toggle not readable (site changed?)"} "error": "toggle not readable (site changed?)"}
return {"ok": True, "node": node, "toggle": name, "value": value} return {"ok": True, "node": node, "toggle": name, "value": value}
@@ -226,15 +230,19 @@ def set_toggle(node, name, value):
ok = _PERM.set_protocol(ws, spec["label"], want == "on") ok = _PERM.set_protocol(ws, spec["label"], want == "on")
else: else:
ok = False ok = False
if not ok: # The fresh-session readback is the source of truth: switch
return {"ok": False, "node": node, "toggle": name, # commits can land slowly or on dialog close, after the
"error": "set failed verification (site changed?)"} # in-flow verify had its chance.
readback = get_toggle(node, name) readback = get_toggle(node, name)
if not readback.get("ok") or readback.get("value") != want: if readback.get("ok") and readback.get("value") == want:
out = {"ok": True, "node": node, "toggle": name,
"value": want}
if not ok:
out["readback_only"] = True
return out
return {"ok": False, "node": node, "toggle": name, return {"ok": False, "node": node, "toggle": name,
"error": "readback mismatch (want %r, got %r)" "error": "readback mismatch (want %r, got %r)"
% (want, readback.get("value"))} % (want, readback.get("value"))}
return {"ok": True, "node": node, "toggle": name, "value": want}
except Exception as e: except Exception as e:
return {"ok": False, "node": node, "toggle": name, return {"ok": False, "node": node, "toggle": name,
"error": "%s: %s" % (type(e).__name__, e)} "error": "%s: %s" % (type(e).__name__, e)}
+3 -1
View File
@@ -549,7 +549,9 @@ def main():
}) })
# Work-first envelope: executable swarm.spawn/followup.create at TOP and BOTTOM # Work-first envelope: executable swarm.spawn/followup.create at TOP and BOTTOM
# (see bin/prompt_envelope.py). Always applied, even if the template has its own [RESULT. # (see bin/prompt_envelope.py). Skipped when the job sets "skip_envelope": true
# (agents whose runtime lacks the enveloped tools, e.g. pip).
if not job.get("skip_envelope"):
import prompt_envelope import prompt_envelope
rendered = prompt_envelope.wrap(job_name, job_id, agent, target, rendered) rendered = prompt_envelope.wrap(job_name, job_id, agent, target, rendered)
+74 -8
View File
@@ -1,27 +1,76 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Side-chat to main-chat work siphon — monitor loop. Side-chat to main-chat siphon — monitor loop (INTEGRATED).
Polls side chats for new messages, runs detection, siphons hits to main. Changes vs original (integrator):
1. Timestamp plumbing (agent 2's open item): message["ts"] is parsed to
epoch seconds and passed as message_ts to detect(), enabling the
15-minute stale-suppression for COMPLETED. Unparseable/missing ts →
backward-compatible (detect proceeds).
2. Author plumbing (agent 3 absent): message["author"] is attached to
the hit as hit.author, so relays attribute the real author instead
of the thread's registered agent.
3. Flood control (agent 4 absent): COMPLETED hits are routed to the
digest buffer instead of individual main-chat relays. ALERT, BLOCKER,
DECISION, MILESTONE still relay individually via siphon().
4. Persistent dedup: every processed hit is marked siphoned (including
digested ones) so a restart never re-relays or re-digests.
This is the integration point for bl. In production: This is the integration point for bl. In production:
- list_sidechats() calls muse-chat-api.py or the sidechat manager - list_sidechats() calls muse-chat-api.py or the sidechat manager
- get_messages() reads thread messages via CDP - get_messages() reads thread messages via CDP
- post_to_main() sends via muse-chat-api.py send to main chat - post_to_main() sends via muse-chat-api.py send to main chat
- flush_digest() should be called on a schedule (e.g. every 30 min) and
For the prototype, all three are injectable (see tests). its output posted to main chat once.
""" """
import time import time
from typing import Callable, Dict, List from datetime import datetime, timezone
from typing import Callable, Dict, List, Optional
from detect import detect, is_opted_out from detect import detect, is_opted_out
from siphon import siphon, RateLimiter from siphon import siphon, mark_siphoned, already_siphoned, RateLimiter
try:
from digest import get_buffer, flush_digest # noqa: F401 (re-export)
except ImportError: # pragma: no cover — digest module optional
get_buffer = None
def flush_digest():
return None
# Message shape: {"id": str, "text": str, "author": str, "ts": str} # Message shape: {"id": str, "text": str, "author": str, "ts": str}
Message = Dict[str, str] Message = Dict[str, str]
# Categories that batch into the digest instead of relaying individually.
DIGESTED_CATEGORIES = {"COMPLETED"}
def _parse_ts(ts) -> Optional[float]:
"""Parse a message timestamp to epoch seconds. None if unparseable."""
if ts is None:
return None
if isinstance(ts, (int, float)):
return float(ts)
s = str(ts).strip()
if not s:
return None
# Epoch as string?
try:
return float(s)
except ValueError:
pass
# ISO-8601 (with optional Z suffix)?
try:
iso = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(iso)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
except ValueError:
return None
def monitor_once( def monitor_once(
list_sidechats: Callable[[], List[Dict[str, str]]], list_sidechats: Callable[[], List[Dict[str, str]]],
@@ -41,6 +90,7 @@ def monitor_once(
""" """
lim = limiter or RateLimiter() lim = limiter or RateLimiter()
new_marks = dict(watermarks) new_marks = dict(watermarks)
digest = get_buffer() if get_buffer else None
for chat in list_sidechats(): for chat in list_sidechats():
tid = chat["id"] tid = chat["id"]
@@ -64,8 +114,24 @@ def monitor_once(
# Update watermark to newest seen # Update watermark to newest seen
new_marks[tid] = mid new_marks[tid] = mid
hit = detect(text, tid, mid, min_confidence) # Persistent dedup first: never reprocess a seen message,
if hit: # even across restarts (marks are set for digested hits too).
if already_siphoned(mid):
continue
message_ts = _parse_ts(msg.get("ts"))
hit = detect(text, tid, mid, min_confidence,
message_ts=message_ts)
if hit is None:
continue
# Author plumbing: real author, never thread-owner-as-author.
hit.author = msg.get("author", "") or ""
if hit.category in DIGESTED_CATEGORIES and digest is not None:
digest.add(hit)
mark_siphoned(mid)
else:
siphon(hit, agent, post_to_main, lim) siphon(hit, agent, post_to_main, lim)
return new_marks return new_marks
+12
View File
@@ -24,6 +24,7 @@ show_usage() {
done done
echo "" echo ""
echo "Global lookups & tools:" echo "Global lookups & tools:"
echo " tui Interactive full-screen Muse TUI & Box fleet console"
echo " tmux [args...] Manage shared Muse tmux sessions (new, send, capture, ls, kill, prune)" echo " tmux [args...] Manage shared Muse tmux sessions (new, send, capture, ls, kill, prune)"
echo " status Fleet overview & node vitality" echo " status Fleet overview & node vitality"
echo " threads List registered threads and sidechats across fleet" echo " threads List registered threads and sidechats across fleet"
@@ -32,6 +33,7 @@ show_usage() {
echo " passkey (or key) View passkey location (VM-only), PIN, & agent approval protocol" echo " passkey (or key) View passkey location (VM-only), PIN, & agent approval protocol"
echo "" echo ""
echo "Per-account commands:" echo "Per-account commands:"
echo " tui Launch interactive TUI for this account"
echo " chat [--thread <id>] Launch interactive conversational shell / REPL" echo " chat [--thread <id>] Launch interactive conversational shell / REPL"
echo " status Check account status, sessions, and unread" echo " status Check account status, sessions, and unread"
echo " threads List active threads and sidechats for account" echo " threads List active threads and sidechats for account"
@@ -48,6 +50,10 @@ show_usage() {
# Direct top-level global actions that do not require an account # Direct top-level global actions that do not require an account
if [[ $# -gt 0 ]]; then if [[ $# -gt 0 ]]; then
case "$1" in case "$1" in
tui)
shift
exec python3 "$NETVM_BIN/muse-tui.py" --mode muse "$@"
;;
tmux) tmux)
shift shift
exec python3 "$NETVM_BIN/muse-tmux.py" "$@" exec python3 "$NETVM_BIN/muse-tmux.py" "$@"
@@ -143,6 +149,12 @@ if [[ ${#POSITIONAL[@]} -eq 0 ]]; then
POSITIONAL=("status") POSITIONAL=("status")
fi fi
# If subcommand is 'tui', launch interactive Muse TUI
if [[ "${POSITIONAL[0]}" == "tui" ]]; then
shift_args=("${POSITIONAL[@]:1}")
exec python3 "$NETVM_BIN/muse-tui.py" --mode muse --account "$ACCOUNT" "${shift_args[@]}"
fi
# If subcommand is 'chat', launch interactive chat REPL # If subcommand is 'chat', launch interactive chat REPL
if [[ "${POSITIONAL[0]}" == "chat" ]]; then if [[ "${POSITIONAL[0]}" == "chat" ]]; then
shift_args=("${POSITIONAL[@]:1}") shift_args=("${POSITIONAL[@]:1}")
+80 -5
View File
@@ -93,6 +93,9 @@ def get_page(node, cdp_url):
return pages[0] return pages[0]
def ev(ws, expr, await_p=False): def ev(ws, expr, await_p=False):
"""Returns None (no traceback) if the CDP WebSocket drops.
(Fix 2026-10-06: uncaught WebSocketConnectionClosedException.)"""
try:
ws.send(json.dumps({ ws.send(json.dumps({
"id": 1, "method": "Runtime.evaluate", "id": 1, "method": "Runtime.evaluate",
"params": {"expression": expr, "returnByValue": True, "awaitPromise": await_p} "params": {"expression": expr, "returnByValue": True, "awaitPromise": await_p}
@@ -110,6 +113,9 @@ def ev(ws, expr, await_p=False):
else: else:
return None return None
return resp.get('result', {}).get('result', {}).get('value') return resp.get('result', {}).get('result', {}).get('value')
except Exception as e:
print(f"CDP evaluate failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
def check_approvals(ws): def check_approvals(ws):
""" """
@@ -362,14 +368,64 @@ def cmd_messages(ws, n=5, width=200):
# Exclude the compose box subtree: a failed send leaves the draft text # Exclude the compose box subtree: a failed send leaves the draft text
# (including the [id:...] tag) in the composer, and scraping it would # (including the [id:...] tag) in the composer, and scraping it would
# produce a false "verified" (2026-10-04 dm.py false-confirmation bug). # produce a false "verified" (2026-10-04 dm.py false-confirmation bug).
result = ev1(ws, f"""(() => {{ # 2026-10-05: row-aware scrape. The message feed alternates sender-header
# rows (div.group/stacked-row, per-message timestamp in
# div.text-caption-1) and message units. Each unit is prefixed with its
# header's timestamp ([8:57 pm]) so sweeps can compute message age. The
# feed is the row-parent whose non-row children hold <p> elements (the
# sidebar shares the row classes). The feed hydrates async after
# navigation, so poll up to ~8s before falling back to the legacy
# paragraph scrape.
result = ev1(ws, f"""(async () => {{
const composer = document.querySelector('[contenteditable="true"]') || const composer = document.querySelector('[contenteditable="true"]') ||
document.querySelector('textarea[placeholder*="Message"]'); document.querySelector('textarea[placeholder*="Message"]');
const noComposer = p => !(composer && composer.contains(p));
const legacy = () => {{
const ps = [...document.querySelectorAll('p')] const ps = [...document.querySelectorAll('p')]
.filter(p => !(composer && composer.contains(p))) .filter(noComposer)
.slice(-{n*2}).map(p=>p.innerText.slice(0,{width})); .slice(-{n*2}).map(p=>p.innerText.slice(0,{width}));
return ps.join('\\n---\\n'); return ps.join('\\n---\\n');
}})()""") }};
const ROWSEL = 'div[class*="group/stacked-row"]';
const findFeed = () => {{
const byParent = new Map();
for (const r of document.querySelectorAll(ROWSEL)) {{
const p = r.parentElement;
if (p) {{
if (!byParent.has(p)) byParent.set(p, []);
byParent.get(p).push(r);
}}
}}
for (const [p, rs] of byParent) {{
const hasMsg = [...p.children].some(c => rs.indexOf(c) === -1 &&
c.querySelectorAll('p').length > 0);
if (hasMsg) return [p, rs];
}}
return [null, null];
}};
let list = null, rows = null;
for (let i = 0; i < 16 && !list; i++) {{
[list, rows] = findFeed();
if (!list) await new Promise(r => setTimeout(r, 500));
}}
if (!list) return legacy();
let curTs = '';
const out = [];
for (const child of [...list.children]) {{
if (composer && child.contains(composer)) continue;
if (rows.indexOf(child) !== -1) {{
const t = child.querySelector('div.text-caption-1');
const txt = t ? t.innerText.trim() : '';
if (txt) curTs = txt;
}} else {{
const ps = [...child.querySelectorAll('p')].filter(noComposer)
.map(p=>p.innerText.slice(0,{width}));
if (ps.length) out.push((curTs ? '[' + curTs + '] ' : '') + ps.join('\\n'));
}}
}}
const res = out.slice(-{n}).join('\\n---\\n');
return res || legacy();
}})()""", True)
print(result) print(result)
def cmd_compose_check(ws): def cmd_compose_check(ws):
@@ -398,8 +454,12 @@ def cmd_wait(ws, timeout=30):
def cdp_navigate(ws, url, timeout_s=30): def cdp_navigate(ws, url, timeout_s=30):
"""Navigate via CDP Page.navigate (proper navigation, waits for commit). """Navigate via CDP Page.navigate (proper navigation, waits for commit).
Returns True if the page URL matches the target after navigation.""" Returns True if the page URL matches the target after navigation.
Returns False (no traceback) if the CDP WebSocket drops mid-call --
the caller retries on False. (Fix 2026-10-06: uncaught
WebSocketConnectionClosedException crashed dm.py sends as nav_failed.)"""
import time as _time import time as _time
try:
ws.send(json.dumps({"id": 2, "method": "Page.navigate", ws.send(json.dumps({"id": 2, "method": "Page.navigate",
"params": {"url": url}})) "params": {"url": url}}))
# Drain until we get the Page.navigate response (id 2). # Drain until we get the Page.navigate response (id 2).
@@ -409,9 +469,18 @@ def cdp_navigate(ws, url, timeout_s=30):
break break
else: else:
return False return False
except Exception as e:
# Browser CDP connection dropped (crash/restart/relay flake).
# Fail cleanly so dm.py logs nav_failed without a traceback.
print(f"CDP navigate failed: {type(e).__name__}: {e}", file=sys.stderr)
return False
# Wait for the URL to settle (SPA client-side routing). # Wait for the URL to settle (SPA client-side routing).
for _ in range(timeout_s): for _ in range(timeout_s):
try:
cur = ev1(ws, "window.location.href", True) cur = ev1(ws, "window.location.href", True)
except Exception as e:
print(f"CDP read failed: {type(e).__name__}: {e}", file=sys.stderr)
return False
if cur and url.rstrip("/").lower() in cur.lower(): if cur and url.rstrip("/").lower() in cur.lower():
return True return True
_time.sleep(1) _time.sleep(1)
@@ -619,7 +688,10 @@ def ev1(ws, expr, await_p=False):
"""Runtime.evaluate that skips CDP event chatter while awaiting its """Runtime.evaluate that skips CDP event chatter while awaiting its
response. ev() reads a single message and can catch an event response. ev() reads a single message and can catch an event
instead (the known None-result quirk); uploads do several DOM instead (the known None-result quirk); uploads do several DOM
calls first, so chatter is likely.""" calls first, so chatter is likely.
Returns None (no traceback) if the CDP WebSocket drops.
(Fix 2026-10-06: uncaught WebSocketConnectionClosedException.)"""
try:
ws.send(json.dumps({ ws.send(json.dumps({
"id": 1, "method": "Runtime.evaluate", "id": 1, "method": "Runtime.evaluate",
"params": {"expression": expr, "returnByValue": True, "params": {"expression": expr, "returnByValue": True,
@@ -631,6 +703,9 @@ def ev1(ws, expr, await_p=False):
continue continue
return resp.get("result", {}).get("result", {}).get("value") return resp.get("result", {}).get("result", {}).get("value")
return None return None
except Exception as e:
print(f"CDP evaluate failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
def cmd_url(ws): def cmd_url(ws):
+4
View File
@@ -1,5 +1,9 @@
import os import os
import sys import sys
import socket
# Prevent unbounded socket hangs across Cloudflare WARP / remote API calls
socket.setdefaulttimeout(15.0)
node = sys.argv[1] node = sys.argv[1]
conf_dir = os.path.expanduser(f"~/.config/muse-cli/{node}") conf_dir = os.path.expanduser(f"~/.config/muse-cli/{node}")
+15 -1
View File
@@ -131,12 +131,19 @@ def parse_threads_blob(blob):
def normalize_thread(t): def normalize_thread(t):
is_main = (
t.get("thread") is False or
t.get("is_main") is True or
(t.get("title") and t.get("title").lower() in ("main chat", "main", "start conversation with muse"))
)
return { return {
"thread_id": t.get("session_id") or t.get("thread_id") or t.get("id"), "thread_id": t.get("session_id") or t.get("thread_id") or t.get("id"),
"title": t.get("title"), "title": t.get("title"),
"pinned": bool(t.get("pinned")), "pinned": bool(t.get("pinned")),
"archived": bool(t.get("archived")), "archived": bool(t.get("archived")),
"updated": t.get("updated"), "updated": t.get("updated"),
"thread": t.get("thread", True),
"is_main": is_main,
} }
@@ -146,7 +153,14 @@ def cmd_list(agent):
code, error, detail, ec = map_failure(rc, err) code, error, detail, ec = map_failure(rc, err)
fail(code, error, detail=detail, exit_code=ec) fail(code, error, detail=detail, exit_code=ec)
threads = [normalize_thread(t) for t in parse_threads_blob(out)] threads = [normalize_thread(t) for t in parse_threads_blob(out)]
print(json.dumps({"ok": True, "agent": agent, "threads": threads})) mains = [t for t in threads if t.get("is_main")]
pinned = [t for t in threads if t.get("pinned") and not t.get("is_main")]
regular = [t for t in threads if not t.get("is_main") and not t.get("pinned")]
mains.sort(key=lambda t: t.get("updated") or "", reverse=True)
pinned.sort(key=lambda t: t.get("updated") or "", reverse=True)
regular.sort(key=lambda t: t.get("updated") or "", reverse=True)
sorted_threads = mains + pinned + regular
print(json.dumps({"ok": True, "agent": agent, "threads": sorted_threads}))
def cmd_mutate(agent, op, thread_id, title=None): def cmd_mutate(agent, op, thread_id, title=None):
+8 -3
View File
@@ -4,8 +4,13 @@ set -euo pipefail
# /etc/resolv.conf is a symlink to the systemd stub (127.0.0.53, unreachable # /etc/resolv.conf is a symlink to the systemd stub (127.0.0.53, unreachable
# in the netns). Replace with a real file (private mount ns) so bwrap # in the netns). Replace with a real file (private mount ns) so bwrap
# children see the fix too: bwrap's --ro-bind /etc is non-recursive and # children see the fix too: bwrap's --ro-bind /etc is non-recursive and
# cannot bind over a dangling symlink. mount --make-rprivate / 2>/dev/null || true
rm -f /etc/resolv.conf if [ -L /etc/resolv.conf ] || ! cmp -s "$NETVM_RESOLV" /etc/resolv.conf 2>/dev/null; then
cp "$NETVM_RESOLV" /etc/resolv.conf TMP="/etc/resolv.conf.netvm.$$"
if cp -f "$NETVM_RESOLV" "$TMP" 2>/dev/null; then
mv -f "$TMP" /etc/resolv.conf 2>/dev/null || rm -f "$TMP" 2>/dev/null || true
fi
fi
exec setpriv --reuid="$NETVM_UID" --regid="$NETVM_GID" --clear-groups \ exec setpriv --reuid="$NETVM_UID" --regid="$NETVM_GID" --clear-groups \
env HOME="$NETVM_HOME" "$@" env HOME="$NETVM_HOME" "$@"
+2 -1
View File
@@ -12,4 +12,5 @@ RESOLV=/etc/netvm/resolv-warp.conf
[ -f "$RESOLV" ] || echo "nameserver 1.1.1.1" > "$RESOLV" [ -f "$RESOLV" ] || echo "nameserver 1.1.1.1" > "$RESOLV"
ip netns exec "$NETNS" env \ ip netns exec "$NETNS" env \
NETVM_RESOLV="$RESOLV" NETVM_UID="$TUID" NETVM_GID="$TGID" NETVM_HOME="$THOME" \ NETVM_RESOLV="$RESOLV" NETVM_UID="$TUID" NETVM_GID="$TGID" NETVM_HOME="$THOME" \
unshare --mount "$SCRIPT_DIR/netvm-enter-inner.sh" "$@" unshare --mount --propagation private "$SCRIPT_DIR/netvm-enter-inner.sh" "$@"
+8
View File
@@ -58,6 +58,14 @@ rm -f "$STRIPPED"
PEER_PK=$(grep -oP '^\s*PublicKey\s*=\s*\K\S+' "$CONF" | head -1) PEER_PK=$(grep -oP '^\s*PublicKey\s*=\s*\K\S+' "$CONF" | head -1)
if [ -n "$PEER_PK" ]; then if [ -n "$PEER_PK" ]; then
nsexec wg set "$WG" peer "$PEER_PK" persistent-keepalive 25 2>/dev/null || true nsexec wg set "$WG" peer "$PEER_PK" persistent-keepalive 25 2>/dev/null || true
# Prefer IPv4 peer endpoint: wg setconf may resolve the Endpoint hostname to
# IPv6, whose handshake then routes into the tunnel itself (no bypass route
# exists for it) and never completes. Observed 2026-10-06 on def.
EPV4=$(getent ahostsv4 "$ENDPOINT" | awk '{print $1}' | sort -u | head -1)
EPPORT=$(grep -oP '^\s*Endpoint\s*=\s*[^:;#]+:\K[0-9]+' "$CONF" | head -1)
if [ -n "$EPV4" ]; then
nsexec wg set "$WG" peer "$PEER_PK" endpoint "${EPV4}:${EPPORT:-2408}" 2>/dev/null || true
fi
fi fi
MTU=$(grep -oP '^\s*MTU\s*=\s*\K\d+' "$CONF" | head -1); MTU=${MTU:-1280} MTU=$(grep -oP '^\s*MTU\s*=\s*\K\d+' "$CONF" | head -1); MTU=${MTU:-1280}
nsexec ip link set "$WG" mtu "$MTU" nsexec ip link set "$WG" mtu "$MTU"
+5 -3
View File
@@ -15,11 +15,13 @@ iptables -t nat -L POSTROUTING -n 2>/dev/null | grep '10.201\.' || echo "(no net
echo "--- CDP relays (connectivity check; pidfile is secondary) ---" echo "--- CDP relays (connectivity check; pidfile is secondary) ---"
for ns in $(ip netns list 2>/dev/null | awk '{print $1}' | grep '^warp-'); do for ns in $(ip netns list 2>/dev/null | awk '{print $1}' | grep '^warp-'); do
netvm_names "${ns#warp-}" netvm_names "${ns#warp-}"
# Registry-pinned CDP ports (same mapping as cdp-relay-watchdog.sh). # Registry-pinned CDP ports (same mapping as netvm-names.sh).
# NOTE: $CDP_PORT from netvm_names() is hash-derived and WRONG here unless # NOTE: keep this case in sync with the pinned mapping — the "*" fallback
# CDP_PORT_OVERRIDE was set at provision time — the pinned mapping is truth. # trusts $CDP_PORT from netvm_names(), which is pinned for registry nodes
# and hash-derived otherwise.
case "$NODE" in case "$NODE" in
muse) port=9410 ;; pip) port=9420 ;; 646) port=9430 ;; opm) port=9440 ;; muse) port=9410 ;; pip) port=9420 ;; 646) port=9430 ;; opm) port=9440 ;;
def) port=9450 ;; dev) port=9455 ;;
*) port="$CDP_PORT" ;; *) port="$CDP_PORT" ;;
esac esac
target="$PEER_IP:$port" target="$PEER_IP:$port"
+12 -2
View File
@@ -23,7 +23,7 @@ import time
# Add bin dir to path for siphon imports # Add bin dir to path for siphon imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from monitor import monitor_once from monitor import monitor_once, flush_digest
from siphon import RateLimiter from siphon import RateLimiter
NETVM_BIN = "/home/super/Projects/NetVM/bin" NETVM_BIN = "/home/super/Projects/NetVM/bin"
@@ -135,7 +135,8 @@ def get_messages(thread_id, since_msg_id):
messages.append({ messages.append({
"id": mid, "id": mid,
"text": chunk[:2000], # truncate long messages "text": chunk[:2000], # truncate long messages
"author": agent, "author": "", # INTEGRATOR 2026-10-06: was `agent`
# (thread owner) -- fabricated authorship; empty = unverified
"ts": str(time.time()), "ts": str(time.time()),
}) })
@@ -249,6 +250,15 @@ def main():
) )
save_watermarks(new_marks) save_watermarks(new_marks)
# INTEGRATOR 2026-10-06: emit batched COMPLETED digest (one message
# instead of N per-message relays). Urgency categories already relayed
# individually inside monitor_once.
digest_text = flush_digest()
if digest_text:
post_fn(digest_text)
log(f"Digest posted ({len(digest_text)} chars).")
log(f"Cycle complete. Watermarks: {len(new_marks)} threads tracked.") log(f"Cycle complete. Watermarks: {len(new_marks)} threads tracked.")
+49 -77
View File
@@ -1,66 +1,30 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Side-chat to main-chat work siphon — siphon action. Side-chat to main-chat work siphon — siphon action (INTEGRATED).
When detection fires, post a summary to main chat with: Changes vs original (integrator; agent 3 of 5 never delivered, so the
- Category badge minimal reversible versions below stand in for its authorship/dedup work):
- One-line summary (never full message text) - format_siphon imported from detect (single definition; honest labeling
- Link back to the source side chat thread + honest authorship live there).
- Confidence score (for transparency) - Deduplication is PERSISTENT: siphoned message IDs are stored as JSON
on disk (SIPHON_STATE_DIR or ~/.siphon-state/siphoned_ids.json) so a
restart can never re-relay. In-memory set kept as a fast path.
- mark_siphoned() writes through to disk on every call.
Safety: Safety (unchanged):
- Rate limited (max N siphons per hour per thread) - Rate limited (max N siphons per hour per thread)
- Never posts full message content - Never posts full message content
- Respects opt-out registry - Respects opt-out registry
- Deduplicates (same message_id never siphoned twice) - Deduplicates (same message_id never siphoned twice, even across restarts)
""" """
import json
import os
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Callable, Optional from typing import Callable, Optional
from detect import SiphonHit, is_opted_out from detect import SiphonHit, is_opted_out, format_siphon # noqa: F401 (re-export)
# --- Follow-up modulation ---
#
# Wire the follow-up modulation table into the siphon so each hit gets
# the right follow-up policy:
# ALERT / BLOCKER / DECISION -> tracked, fast fuse for ALERT/BLOCKER
# COMPLETED / MILESTONE -> untracked (no nudge budget burned)
#
# modulate.py must be landed on bl before this runs (rollout step 1).
# If the import fails we degrade to the old behavior: post the summary
# with no follow-up tags (fail-closed toward visibility, not tracking).
try:
from modulate import for_siphon_hit, render_tags
_MODULATION_AVAILABLE = True
except ImportError: # pragma: no cover - deploy keeps modulate.py present
_MODULATION_AVAILABLE = False
for_siphon_hit = None
render_tags = None
def policy_for_hit(hit: SiphonHit):
"""Follow-up policy for a siphon hit, or None when untracked.
COMPLETED / MILESTONE hits return None (post the summary, create no
follow-up record). ALERT / BLOCKER / DECISION return a Policy whose
tags render into the canonical bracket vocabulary.
"""
if not _MODULATION_AVAILABLE:
return None
return for_siphon_hit(hit.category)
def is_tracked(hit: SiphonHit) -> bool:
"""True when this hit should create a follow-up record.
Callers that route tracked posts through dm.py --expect-reply (so a
dm_followup record is actually created) can use this to choose the
post path. Untracked hits post as plain summaries.
"""
return policy_for_hit(hit) is not None
# --- Rate limiting --- # --- Rate limiting ---
@@ -82,9 +46,37 @@ class RateLimiter:
return True return True
# --- Deduplication --- # --- Deduplication (persistent) ---
_siphoned_ids: set = set() _STATE_DIR = os.environ.get(
"SIPHON_STATE_DIR", os.path.expanduser("~/.siphon-state"))
DEDUP_FILE = os.path.join(_STATE_DIR, "siphoned_ids.json")
_DEDUP_MAX_IDS = 5000 # bound disk growth; oldest evicted first
def _load_siphoned() -> set:
try:
with open(DEDUP_FILE) as f:
data = json.load(f)
ids = data.get("ids", []) if isinstance(data, dict) else []
return set(ids)
except (OSError, ValueError):
return set()
def _save_siphoned(ids: set) -> None:
try:
os.makedirs(_STATE_DIR, exist_ok=True)
trimmed = sorted(ids)[-_DEDUP_MAX_IDS:]
tmp = DEDUP_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump({"ids": trimmed, "updated": time.time()}, f)
os.replace(tmp, DEDUP_FILE)
except OSError:
pass # dedup degrades to in-memory; never crash the relay on IO
_siphoned_ids: set = _load_siphoned()
def already_siphoned(message_id: str) -> bool: def already_siphoned(message_id: str) -> bool:
@@ -93,6 +85,11 @@ def already_siphoned(message_id: str) -> bool:
def mark_siphoned(message_id: str): def mark_siphoned(message_id: str):
_siphoned_ids.add(message_id) _siphoned_ids.add(message_id)
_save_siphoned(_siphoned_ids)
def siphoned_count() -> int:
return len(_siphoned_ids)
# --- Siphon action --- # --- Siphon action ---
@@ -106,21 +103,6 @@ CATEGORY_EMOJI = {
} }
def format_siphon(hit: SiphonHit, agent_name: str = "sidechat") -> str:
"""
Format a siphon message for main chat.
Never includes full message text — summary + link only.
"""
emoji = CATEGORY_EMOJI.get(hit.category, "📋")
thread_url = f"https://muse.ai/thread/{hit.thread_id}"
return (
f"{emoji} [{hit.category}] from {agent_name} side chat\n"
f"{hit.summary}\n"
f"→ {thread_url}\n"
f"(confidence {hit.confidence:.0%})"
)
def siphon(hit: SiphonHit, def siphon(hit: SiphonHit,
agent_name: str, agent_name: str,
post_to_main: Callable[[str], bool], post_to_main: Callable[[str], bool],
@@ -142,16 +124,6 @@ def siphon(hit: SiphonHit,
return False return False
text = format_siphon(hit, agent_name) text = format_siphon(hit, agent_name)
# Follow-up modulation: tracked hits (ALERT/BLOCKER/DECISION) get
# the canonical follow-up tags appended — [reply:expected],
# [reply:timeout=N], [reply:nudges=N], [reply:escalate=X], and
# [input:siphon] for the audit trail. Untracked hits
# (COMPLETED/MILESTONE) post as plain summaries.
policy = policy_for_hit(hit)
if policy is not None:
text = text + "\n" + render_tags(policy)
ok = post_to_main(text) ok = post_to_main(text)
if ok: if ok:
mark_siphoned(hit.message_id) mark_siphoned(hit.message_id)
+41 -1
View File
@@ -59,8 +59,40 @@ def register_session(parent, session_id, title=None, prompt=None):
return entry return entry
def get_active_sessions(parent=None): DEFAULT_TTL_SECONDS = 3600 # 1 hour idle TTL
def prune_stale_sessions(ttl_seconds=DEFAULT_TTL_SECONDS):
"""Archive active sessions whose last activity exceeds ttl_seconds."""
data = load_sessions()
now = datetime.now(timezone.utc)
changed = False
for sid, s in data.items():
if s.get("status") == "active":
last_act = s.get("last_activity_at") or s.get("spawned_at")
if last_act:
try:
dt = datetime.fromisoformat(last_act.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
if (now - dt).total_seconds() >= ttl_seconds:
s["status"] = "archived"
s["archived_at"] = utcnow()
s["archive_reason"] = f"idle_ttl_exceeded_{ttl_seconds}s"
changed = True
except Exception:
pass
if changed:
save_sessions(data)
def get_active_sessions(parent=None, auto_prune=True, ttl_seconds=DEFAULT_TTL_SECONDS):
"""Retrieve all active subagent sessions, optionally filtered by parent.""" """Retrieve all active subagent sessions, optionally filtered by parent."""
if auto_prune:
try:
prune_stale_sessions(ttl_seconds=ttl_seconds)
except Exception:
pass
data = load_sessions() data = load_sessions()
results = [] results = []
for s in data.values(): for s in data.values():
@@ -88,6 +120,14 @@ def complete_session(session_id, note=None):
return update_session(session_id, **kwargs) return update_session(session_id, **kwargs)
def archive_session(session_id, reason=None):
"""Mark a subagent session archived."""
kwargs = {"status": "archived", "archived_at": utcnow()}
if reason:
kwargs["archive_reason"] = reason
return update_session(session_id, **kwargs)
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "list": if len(sys.argv) > 1 and sys.argv[1] == "list":
print(json.dumps(load_sessions(), indent=2)) print(json.dumps(load_sessions(), indent=2))
+64 -14
View File
@@ -30,8 +30,9 @@ sys.path.insert(0, _SWARM_DIR)
sys.path.insert(0, _BIN_DIR) sys.path.insert(0, _BIN_DIR)
from poller import find_pending_slots from poller import find_pending_slots
from executor import execute_task, _looks_like_shell from executor import execute_task, _looks_like_shell, extract_shell_command, execute_task_in_tmux
from reporter import post_result, attach_slot from reporter import post_result, attach_slot
from mainloop_notify import notify_via_mainloop
# Fast gateway integration # Fast gateway integration
try: try:
@@ -49,7 +50,7 @@ except ImportError:
POLL_INTERVAL = 60 # seconds between poll cycles POLL_INTERVAL = 60 # seconds between poll cycles
STALE_MINUTES = 5 # slots older than this with no attach are workable STALE_MINUTES = 5 # slots older than this with no attach are workable
WORKER_POOL = ["dev", "def", "muse"] WORKER_POOL = ["muse"] # only dispatch to fully authenticated agent nodes
# === SAFETY SWITCH === # === SAFETY SWITCH ===
# True -> observe only: log what WOULD be done, execute/post nothing. # True -> observe only: log what WOULD be done, execute/post nothing.
@@ -65,12 +66,12 @@ log = logging.getLogger("swarm-worker")
def _select_worker(preferred=None): def _select_worker(preferred=None):
if preferred and HAS_MUSE_HYBRID and muse_hybrid.is_node_configured(preferred): if preferred and preferred in WORKER_POOL and HAS_MUSE_HYBRID and muse_hybrid.is_node_configured(preferred):
return preferred return preferred
for candidate in WORKER_POOL: for candidate in WORKER_POOL:
if HAS_MUSE_HYBRID and muse_hybrid.is_node_configured(candidate): if HAS_MUSE_HYBRID and muse_hybrid.is_node_configured(candidate):
return candidate return candidate
return preferred or "dev" return "muse"
def process_slot(slot): def process_slot(slot):
@@ -81,19 +82,56 @@ def process_slot(slot):
agent_label = slot.get("agent_label") agent_label = slot.get("agent_label")
sidechat_id = slot.get("sidechat_id") sidechat_id = slot.get("sidechat_id")
tag = "%s/%s" % (swarm_id, slot_index) tag = "%s/%s" % (swarm_id, slot_index)
short_id = swarm_id[3:19] if str(swarm_id).startswith("sw-") else str(swarm_id)[:16]
session_name = f"sw-{short_id}-s{slot_index}"
if DRY_RUN: if DRY_RUN:
log.info("[dry-run] would execute slot %s (agent=%s, task %.80r)", log.info("[dry-run] would execute slot %s (agent=%s, task %.80r)",
tag, agent_label, task_text) tag, agent_label, task_text)
return True return True
# If the task is NOT a shell command, dispatch it to an ephemeral Muse subagent. # 1. Check if the task is or contains an executable shell command
is_shell = _looks_like_shell(task_text) cmd = extract_shell_command(task_text)
if not is_shell and HAS_MUSE_HYBRID: if cmd:
log.info("executing slot %s in host tmux session %s on bl", tag, session_name)
# Attach/claim slot in box state
attach_slot(swarm_id, slot_index, "swarm-worker", session_id=session_name)
try:
result = execute_task_in_tmux(session_name, cmd)
except Exception:
log.error("tmux executor crashed on slot %s:\n%s", tag, traceback.format_exc())
result = {"success": False, "output": "",
"error": "tmux executor crashed: see worker log"}
payload = {
"ok": bool(result.get("success")),
"output": result.get("output") or "",
"error": result.get("error"),
}
try:
posted = post_result(swarm_id, slot_index, payload)
except Exception:
log.error("reporter crashed on slot %s:\n%s", tag, traceback.format_exc())
posted = False
# Post completion note to the slot sidechat for main loop visibility
summary_msg = payload.get("output") or payload.get("error") or "completed"
try:
notified = notify_via_mainloop(swarm_id, slot_index, summary_msg, worker_id="swarm-worker")
log.info("slot %s sidechat notification: %s", tag, notified)
except Exception as ne:
log.warning("failed to post sidechat notification for %s: %s", tag, ne)
log.info("slot %s done: ok=%s posted=%s (%.1fs)",
tag, payload["ok"], posted,
float(result.get("duration_s") or 0.0))
return bool(payload["ok"]) and posted
# 2. If the task is purely prose/instructions, dispatch to a verified agent subagent
if HAS_MUSE_HYBRID:
worker_agent = _select_worker(agent_label) worker_agent = _select_worker(agent_label)
log.info("dispatching subagent slot %s to %s", tag, worker_agent) log.info("dispatching prose subagent slot %s to %s", tag, worker_agent)
try: try:
# 1. Start an ephemeral subagent session
title = f"sw-{swarm_id[:16]}-s{slot_index}" title = f"sw-{swarm_id[:16]}-s{slot_index}"
sess, err = muse_hybrid.start_session(worker_agent, title=title) sess, err = muse_hybrid.start_session(worker_agent, title=title)
if err or not sess or not sess.get("session_id"): if err or not sess or not sess.get("session_id"):
@@ -103,12 +141,19 @@ def process_slot(slot):
sub_sid = sess["session_id"] sub_sid = sess["session_id"]
log.info("subagent session %s created for slot %s on %s", sub_sid, tag, worker_agent) log.info("subagent session %s created for slot %s on %s", sub_sid, tag, worker_agent)
# 2. Attach/claim the slot in box state with subagent session_id # Attach/claim slot in box state
attached = attach_slot(swarm_id, slot_index, worker_agent, session_id=sub_sid) attached = attach_slot(swarm_id, slot_index, worker_agent, session_id=sub_sid)
if not attached: if not attached:
log.warning("failed to attach slot %s to %s; proceeding with dispatch", tag, worker_agent) log.warning("failed to attach slot %s to %s; proceeding with dispatch", tag, worker_agent)
# 3. Format prompt with authentic Operator Directive and RESULT expectation # Register in subagent_tracker
try:
import subagent_tracker
subagent_tracker.register_session(worker_agent, sub_sid, title=title, prompt=task_text[:200])
except Exception:
pass
# Format prompt with authentic Operator Directive and RESULT expectation
if HAS_PROMPT_ENVELOPE and hasattr(prompt_envelope, "wrap_subagent_task"): if HAS_PROMPT_ENVELOPE and hasattr(prompt_envelope, "wrap_subagent_task"):
prompt_body = prompt_envelope.wrap_subagent_task(tag, task_text) prompt_body = prompt_envelope.wrap_subagent_task(tag, task_text)
else: else:
@@ -122,7 +167,7 @@ def process_slot(slot):
f"(or [RESULT {tag}] FAIL: <reason> if the task could not be completed)\n" f"(or [RESULT {tag}] FAIL: <reason> if the task could not be completed)\n"
) )
# 4. Asynchronously send message to subagent session # Asynchronously send message to subagent session
res, send_err = muse_hybrid.send_message(worker_agent, prompt_body, thread_id=sub_sid, wait=0) res, send_err = muse_hybrid.send_message(worker_agent, prompt_body, thread_id=sub_sid, wait=0)
if send_err: if send_err:
log.error("failed to send task to subagent %s on %s: %s", sub_sid, worker_agent, send_err) log.error("failed to send task to subagent %s on %s: %s", sub_sid, worker_agent, send_err)
@@ -134,8 +179,8 @@ def process_slot(slot):
log.error("subagent dispatch crashed on slot %s:\n%s", tag, traceback.format_exc()) log.error("subagent dispatch crashed on slot %s:\n%s", tag, traceback.format_exc())
return False return False
# Otherwise fallback to sandboxed host execution # 3. Fallback to sandboxed host execution
log.info("executing slot %s in sandbox (agent=%s)", tag, agent_label) log.info("executing slot %s in fallback sandbox (agent=%s)", tag, agent_label)
try: try:
result = execute_task(task_text) result = execute_task(task_text)
except Exception: except Exception:
@@ -154,6 +199,11 @@ def process_slot(slot):
log.error("reporter crashed on slot %s:\n%s", tag, traceback.format_exc()) log.error("reporter crashed on slot %s:\n%s", tag, traceback.format_exc())
posted = False posted = False
try:
notify_via_mainloop(swarm_id, slot_index, payload.get("output") or "done", worker_id="swarm-worker")
except Exception:
pass
log.info("slot %s done: ok=%s posted=%s (%.1fs)", log.info("slot %s done: ok=%s posted=%s (%.1fs)",
tag, payload["ok"], posted, tag, payload["ok"], posted,
float(result.get("duration_s") or 0.0)) float(result.get("duration_s") or 0.0))
+140 -1
View File
@@ -85,13 +85,152 @@ def _looks_like_shell(task_text):
if "/" in first: if "/" in first:
return os.path.isfile(first) and os.access(first, os.X_OK) return os.path.isfile(first) and os.access(first, os.X_OK)
# If it's a bare command name, it must exist in standard system bin paths # If it's a bare command name, it must exist in standard system bin paths
for p in ("/bin", "/usr/bin", "/usr/local/bin"): for p in ("/bin", "/usr/bin", "/usr/local/bin", "/home/super/Projects/NetVM/bin", "/home/super/.local/bin"):
candidate = os.path.join(p, first) candidate = os.path.join(p, first)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK): if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return True return True
return False return False
def extract_shell_command(task_text):
"""Extract an executable shell command from task text if present."""
t = (task_text or "").strip()
if not t:
return None
if _looks_like_shell(t):
return t
# Check for "Run: <cmd>" or "Execute this shell command...: <cmd>"
m = re.search(r"(?:Run|Execute)(?:\s+this\s+shell\s+command(?:\s+and\s+report\s+its\s+full\s+output)?)?:\s*[`'\"]?([^`'\n]+)[`'\"]?", t, re.IGNORECASE)
if m:
candidate = m.group(1).strip()
if candidate:
return candidate
# Check for markdown code blocks ```bash ... ``` or ```sh ... ```
m = re.search(r"```(?:bash|sh)?\n(.*?)\n```", t, re.DOTALL)
if m:
candidate = m.group(1).strip()
if candidate:
return candidate
# Check for single backticked command
m = re.search(r"`([^`\n]+)`", t)
if m:
candidate = m.group(1).strip()
if _looks_like_shell(candidate):
return candidate
return None
TMUX_SOCKET = "/tmp/tmux-muse.sock"
TMUX_LOG_DIR = "/home/super/Projects/NetVM/logs/tmux"
def execute_task_in_tmux(session_name, cmd_str, timeout=300):
"""Execute a task inside a dedicated tmux session on /tmp/tmux-muse.sock.
Captures output to logs/tmux/{session_name}.log, tracks return code via
status file, and returns:
dict(success=bool, output=str, duration_s=float, error=str|None)
"""
os.makedirs(TMUX_LOG_DIR, exist_ok=True)
started = time.monotonic()
log_file = os.path.join(TMUX_LOG_DIR, f"{session_name}.log")
exit_file = f"/tmp/{session_name}.exit"
script_file = f"/tmp/{session_name}.sh"
# Clean up prior artifacts
for f in (exit_file, script_file):
try:
if os.path.exists(f):
os.remove(f)
except Exception:
pass
# Write wrapper script
with open(script_file, "w", encoding="utf-8") as sf:
sf.write("#!/usr/bin/env bash\n")
sf.write("export PATH=\"/home/super/Projects/NetVM/bin:/home/super/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH\"\n")
sf.write("cd /home/super/Projects/NetVM\n")
sf.write(f"{cmd_str}\n")
sf.write(f"echo $? > \"{exit_file}\"\n")
os.chmod(script_file, 0o755)
# Kill any existing session with this name
subprocess.run(["tmux", "-S", TMUX_SOCKET, "kill-session", "-t", session_name],
capture_output=True)
# Start tmux session
tmux_cmd = f"bash \"{script_file}\" > \"{log_file}\" 2>&1"
res = subprocess.run(
["tmux", "-S", TMUX_SOCKET, "new-session", "-d", "-s", session_name, tmux_cmd],
capture_output=True, text=True
)
if res.returncode != 0:
dur = round(time.monotonic() - started, 3)
return {
"success": False,
"output": "",
"duration_s": dur,
"error": f"Failed to create tmux session: {res.stderr.strip()}",
}
# Poll for completion or timeout
deadline = started + timeout
rc = None
while time.monotonic() < deadline:
if os.path.exists(exit_file):
try:
with open(exit_file, "r") as ef:
rc = int(ef.read().strip())
break
except Exception:
pass
check = subprocess.run(
["tmux", "-S", TMUX_SOCKET, "has-session", "-t", session_name],
capture_output=True
)
if check.returncode != 0 and os.path.exists(exit_file):
break
time.sleep(0.5)
dur = round(time.monotonic() - started, 3)
# Clean up tmux session if still running
subprocess.run(["tmux", "-S", TMUX_SOCKET, "kill-session", "-t", session_name],
capture_output=True)
# Read output log
output = ""
if os.path.exists(log_file):
try:
with open(log_file, "r", encoding="utf-8", errors="replace") as lf:
output = lf.read()[:OUTPUT_TRUNCATE]
except Exception as e:
output = f"Error reading log: {e}"
# Cleanup temporary script and exit file
for f in (exit_file, script_file):
try:
if os.path.exists(f):
os.remove(f)
except Exception:
pass
if rc is None:
return {
"success": False,
"output": output,
"duration_s": dur,
"error": f"timeout: exceeded {timeout}s in tmux session",
}
return {
"success": (rc == 0),
"output": output,
"duration_s": dur,
"error": None if (rc == 0) else f"exit code {rc}",
}
def _refused(task_text): def _refused(task_text):
return bool(_REFUSE_RE.search(task_text)) return bool(_REFUSE_RE.search(task_text))
+14 -4
View File
@@ -37,14 +37,24 @@ def notify_via_mainloop(swarm_id, slot_index, message, worker_id="swarm-worker",
Returns: Returns:
True on success (or dry-run), False on failure (logged, not raised). True on success (or dry-run), False on failure (logged, not raised).
""" """
target_name = "sw-%s-s%s" % (swarm_id, slot_index) target_name = f"{swarm_id}-s{slot_index}" if str(swarm_id).startswith("sw-") else f"sw-{swarm_id}-s{slot_index}"
summary = (message or "").strip().replace("\n", " ")[:NOTE_CHARS] summary = (message or "").strip().replace("\n", " ")[:NOTE_CHARS]
note = "[SWARM-DONE %s/%s] %s" % (swarm_id, slot_index, summary) tag = "%s/%s" % (swarm_id, slot_index)
note = "[SWARM-DONE %s] %s" % (tag, summary)
sender = worker_id if worker_id in ("muse", "pip", "646", "opm", "dev", "def", "super") else "super"
to_agent = "opm"
try: try:
sys.path.insert(0, BIN) sys.path.insert(0, BIN)
import dm import dm
uuid = dm.resolve_sidechat_target(target_name) uuid = dm.resolve_sidechat_target(target_name)
if not uuid:
sc = dm.load_sidechat_map() if hasattr(dm, "load_sidechat_map") else {}
entry = sc.get(target_name, {})
uuid = entry.get("thread_uuid")
if entry.get("agent"):
to_agent = entry.get("agent")
except Exception as e: except Exception as e:
print("notify_via_mainloop: target resolve failed for %s: %s" print("notify_via_mainloop: target resolve failed for %s: %s"
% (target_name, e), file=sys.stderr) % (target_name, e), file=sys.stderr)
@@ -55,8 +65,8 @@ def notify_via_mainloop(swarm_id, slot_index, message, worker_id="swarm-worker",
return False return False
cmd = [sys.executable, DM_PY, "send", cmd = [sys.executable, DM_PY, "send",
"--agent", worker_id, "--agent", sender,
"--to", worker_id, "--to", to_agent,
"--target", uuid, "--target", uuid,
note] note]
if dry_run: if dry_run:
+2
View File
@@ -96,6 +96,8 @@ def post_result(swarm_id, slot_index, result_dict, dry_run=False):
log.error("post_result %s/%s: box-ctl ok=false: %s", log.error("post_result %s/%s: box-ctl ok=false: %s",
swarm_id, slot_index, str(resp)[:500]) swarm_id, slot_index, str(resp)[:500])
return False return False
return True
def attach_slot(swarm_id, slot_index, agent_id, session_id=None, dry_run=False): def attach_slot(swarm_id, slot_index, agent_id, session_id=None, dry_run=False):
"""Claim/attach a swarm slot to an agent in box state. """Claim/attach a swarm slot to an agent in box state.
+20 -4
View File
@@ -12,11 +12,24 @@
# - New failures: print each new "relaunch FAILED" line, update the # - New failures: print each new "relaunch FAILED" line, update the
# watermark to the newest line, exit 1. # watermark to the newest line, exit 1.
# #
# Self-contained: no arguments, no nested quoting. Safe to call from cron # --no-advance: peek-only read. New failures are printed (same output and
# or from the box CLI. # exit codes as above) but the watermark is NOT advanced. The web
# surface (via `box-ctl watchdog-alerts --no-advance`) should always
# pass this flag so UI polling never churns the watermark out from
# under the CLI. CLI runs without the flag keep advance-on-read.
#
# Self-contained: safe to call from cron or from the box CLI.
set -u set -u
NO_ADVANCE=0
for arg in "$@"; do
case "$arg" in
--no-advance) NO_ADVANCE=1 ;;
*) echo "watchdog-alert-check.sh: unknown argument: $arg" >&2; exit 2 ;;
esac
done
LOG="/home/super/Projects/NetVM/chromebox-watchdog.log" LOG="/home/super/Projects/NetVM/chromebox-watchdog.log"
WATERMARK="/home/super/Projects/NetVM/watchdog-alert-watermark.txt" WATERMARK="/home/super/Projects/NetVM/watchdog-alert-watermark.txt"
@@ -48,7 +61,10 @@ fi
[ "${#new_lines[@]}" -gt 0 ] || exit 0 [ "${#new_lines[@]}" -gt 0 ] || exit 0
# Report new failures and advance the watermark to the newest line. # Report new failures and advance the watermark to the newest line
# (skipped in --no-advance peek mode).
printf '%s\n' "${new_lines[@]}" printf '%s\n' "${new_lines[@]}"
printf '%s\n' "${failed[-1]}" > "$WATERMARK" if [ "$NO_ADVANCE" -eq 0 ]; then
printf '%s\n' "${failed[-1]}" > "$WATERMARK"
fi
exit 1 exit 1
+3 -3
View File
@@ -64,7 +64,7 @@ host → veth IP:port (e.g. 10.201.87.2:9420)
### chromebox-watchdog (browser health) ### chromebox-watchdog (browser health)
- **Script:** `/home/super/Projects/NetVM/bin/chromebox-watchdog.sh` - **Script:** `/home/super/Projects/NetVM/bin/chromebox-watchdog.sh`
- **Timers:** `chromebox-watchdog-<profile>.timer` (one per profile: muse, pip, 646, opm) - **Timers:** `chromebox-watchdog-<profile>.timer` (one per profile — every active registry node: muse, pip, 646, opm, def, dev)
- **Cadence:** every 2 minutes - **Cadence:** every 2 minutes
- **Log:** `/home/super/Projects/NetVM/chromebox-watchdog.log` (10 MB rotation, 1 backup gen) - **Log:** `/home/super/Projects/NetVM/chromebox-watchdog.log` (10 MB rotation, 1 backup gen)
- **Per-profile Chromium output:** `/home/super/Projects/NetVM/chromebox-<profile>.log` - **Per-profile Chromium output:** `/home/super/Projects/NetVM/chromebox-<profile>.log`
@@ -233,7 +233,7 @@ in the NetVM repo — check `git status` if it's gone.
**Symptoms:** `pgrep -af netvm-cdp-relay` shows relays on ports like 9269, 9278, **Symptoms:** `pgrep -af netvm-cdp-relay` shows relays on ports like 9269, 9278,
9353, 10239, 10355 (hash-derived, not registry ports). 9353, 10239, 10355 (hash-derived, not registry ports).
**Cause:** old node-ups or queue tests. Harmless but confusing. **Cause:** old node-ups or queue tests. Harmless but confusing.
**Fix:** kill them. Only 9410/9420/9430/9440 should be running. **Fix:** kill them. Only the registry ports (9410/9420/9430/9440/9450/9455) should be running.
## Fleet Status From Blind Shells ## Fleet Status From Blind Shells
@@ -246,7 +246,7 @@ falls back to host watchdog evidence (`bin/host_evidence.py`):
`cdp-relay-watchdog.log` / `chromebox-watchdog.log` (both are `cdp-relay-watchdog.log` / `chromebox-watchdog.log` (both are
silent-when-healthy) prove the node is up → `ACTIVE [*]`. silent-when-healthy) prove the node is up → `ACTIVE [*]`.
- `UNKNOWN` means neither live probes nor host evidence could decide - `UNKNOWN` means neither live probes nor host evidence could decide
(e.g. def/dev have no relay-monitor coverage). (e.g. watchdog timers not installed yet for that node).
- Host evidence never overrides a live local signal, so a fresh outage - Host evidence never overrides a live local signal, so a fresh outage
observed on the host always wins over a minutes-old watchdog run. observed on the host always wins over a minutes-old watchdog run.
+37 -15
View File
@@ -48,26 +48,48 @@ settled-state probes (`DOM-PAGE-STRUCTURE.md` §3.2). The dialog is found
**by text, not by structure** — this is the central fragility of the current **by text, not by structure** — this is the central fragility of the current
implementation. implementation.
Known structural facts: Known structural facts (Verified 2026-10-06 live capture on fleet):
- The dialog is in-DOM (React-rendered), so `Runtime.evaluate` sees it; no - The dialog is in-DOM (React-rendered), so `Runtime.evaluate` sees it; no
shadow-DOM piercing has been needed so far. shadow-DOM piercing needed.
- Buttons are plain `<button>` elements matched by innerText - Container: `div[data-testid="hatch-inline-approval-card"]` with
(`allow` / `deny` / `block`, case-insensitive). `data-hatch-approval-surface="panel"`.
- Per `AGENTS.md` (2026-10-03): React selectors behave identically in - Header: `div[data-testid="approval-panel-header"]`.
headful and headless environments, so selectors captured headless apply - Primary Allow action: `button[data-hatch-approval-primary-action="true"]` ("Allow once").
to the user's browser too — and vice versa. - Dropdown options: `button[data-slot="dropdown-menu-trigger"]` (contains "Always allow").
- Deny action: `<button>` with innerText "Deny".
- Secondary/Background queued approvals surface:
`div[data-testid="hatch-inline-approval-card"][data-hatch-background-approval-surface="true"]`
with `<p class="text-footnote text-text-secondary">N tasks need review</p>`
and `<button data-pel-click="chat_background_approval_review">Review</button>`.
Candidate selectors to verify on next live capture (none confirmed yet): ### Confirmed Live Selectors (2026-10-06):
```javascript ```javascript
'[role="dialog"]', // Active approval panel container
'[role="alertdialog"]', 'div[data-testid="hatch-inline-approval-card"]'
'[data-testid*="dialog"]', // Panel header
'[data-testid*="approval"]', '[data-testid="approval-panel-header"]'
'[data-testid*="permission"]', // Primary action button ("Allow once")
// button-level (confirmed pattern, unconfirmed testids): 'button[data-hatch-approval-primary-action="true"]'
'button' // innerText matches /allow once|always allow|deny/i // Background review surface ("N tasks need review" / "A task needs review")
'[data-hatch-background-approval-surface="true"]'
// Background review button trigger
'[data-pel-click="chat_background_approval_review"]'
``` ```
### Queued Background Task Reviews ("N tasks need review")
When multiple scheduled tasks or background operations trigger approval prompts concurrently (e.g. `opm` with scheduled Fleet Health Monitor runs):
1. Muse renders the first approval card inline over chat.
2. Below it, Muse docks a background approval banner:
`<div data-testid="hatch-inline-approval-card" data-hatch-background-approval-surface="true">`
stating `"N tasks need review"` with a `"Review"` button (`data-pel-click="chat_background_approval_review"`).
3. Allowing or denying the active approval immediately advances the queue: the next queued task pops into the active inline panel, decrementing the background count (e.g. from 2 down to "A task needs review" to clear).
4. Automated approval engines must inspect both the active card and `[data-hatch-background-approval-surface="true"]` to confirm whether agents remain blocked.
### Troubleshooting Blocked Agents
- **In `muse-tui`**: Type `/blocked` (or press `[a]` / `F2`) from any view to open the Approvals & Blocked Tasks Drawer. Use `j`/`k` or arrow keys to navigate between blocked nodes; press `[1]` to Allow, `[2]` Always, `[3]` Deny, `[R]` Proceed, `[X]` Dismiss. Actions target the highlighted agent.
- **In CLI**: Run `box blocked` or `box approvals` to view fleet approval status; use `box approvals allow <node>` to approve.
## 3. How `check_approvals` works today ## 3. How `check_approvals` works today
Location: `bin/muse-chat-api.py`, `check_approvals(ws)` (~line 70). Location: `bin/muse-chat-api.py`, `check_approvals(ws)` (~line 70).
+12 -5
View File
@@ -37,16 +37,23 @@ Static: `permissions.connector_defaults`,
`permissions.web_access` (`auto_allow`/`always_ask`); `permissions.web_access` (`auto_allow`/`always_ask`);
`permissions.advanced.transparent_proxy|tls_interception| `permissions.advanced.transparent_proxy|tls_interception|
sni_mismatch_rejection`, `data_controls.ai_improvement` (`on`/`off`); sni_mismatch_rejection`, `data_controls.ai_improvement` (`on`/`off`);
`general.theme` (match/default/blue/purple/pink/orange/green/ `general.theme` (avatar/default/blue/purple/pink/orange/green/
beige/monochrome). beige/monochrome; `avatar` = "Match my avatar").
Families: `permissions.websites:<host>` (`Allow`/`Ask`/`Deny`), Families: `permissions.websites:<host>` (`Allow`/`Ask`/`Deny`),
`permissions.protocols:<slug>` (`on`/`off`; slugs discovered live, `permissions.protocols:<slug>` (`on`/`off`; network primitives
e.g. `mcp-sse`, `mcp-streamable`, `agent-skills`, `mcp-apps`, `outbound-ssh`, `smtp`, `imap-pop3`, `database`, `ftp`, `dns`,
`mcp-oauth`). `other-tcp`, `other-udp` pinned live 2026-10-06, MCP titles kept
defensively; unique substrings like `ssh` also resolve).
Every `set` verifies in place and reads back through a fresh Every `set` verifies in place and reads back through a fresh
session; readback mismatch reports failure, never partial success. session; readback mismatch reports failure, never partial success.
Switch commits can land slowly (or on dialog close), so in-flow
verifies poll and the fresh readback is the source of truth; sets
that only the readback confirms carry `"readback_only": true`.
Website Ask/Deny is one-way: the override row leaves the allowed
list (no add UI), verified by absence; absent hosts read as
"not in Websites list" (effective: web-access default).
Caller errors (unknown node/toggle/tab/value) raise `MenuError` Caller errors (unknown node/toggle/tab/value) raise `MenuError`
before any CDP traffic. Transport failures return `{"ok": False}`. before any CDP traffic. Transport failures return `{"ok": False}`.
+360 -78
View File
@@ -118,11 +118,11 @@
"type": "persistent" "type": "persistent"
}, },
"heartbeat": { "heartbeat": {
"thread_uuid": "757198c3-c1b2-48b8-ba2b-062c84f71b02", "thread_uuid": "557a4177-901a-4b20-b193-21ac992d49a8",
"agent": "opm", "agent": "opm",
"title": "heartbeat", "title": "heartbeat",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T18:50:05.500146+00:00" "created_at": "2026-10-06T03:10:03.642110+00:00"
}, },
"heartbeat-opm": { "heartbeat-opm": {
"thread_uuid": "ac8c3366-a2b4-407d-adc7-bfa18903c0f5", "thread_uuid": "ac8c3366-a2b4-407d-adc7-bfa18903c0f5",
@@ -240,18 +240,18 @@
"type": "persistent" "type": "persistent"
}, },
"box-http-health": { "box-http-health": {
"thread_uuid": "ed03c343-c3b7-46aa-9d36-0d15e97ff6df", "thread_uuid": "5fcb395e-24e4-4b4a-92a8-85edaba710ba",
"agent": "646", "agent": "646",
"title": "box-http-health-2026-10-05T15:45:00.375935+00:00", "title": "box-http-health-2026-10-06T05:41:24.064445+00:00",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T15:46:24.751658+00:00" "created_at": "2026-10-06T05:41:47.860796+00:00"
}, },
"box-service-health": { "box-service-health": {
"thread_uuid": "7e86d12c-0126-46be-8daf-b049b2d67364", "thread_uuid": "da4f9f77-f1de-44ef-a7f3-b46519bc3542",
"agent": "646", "agent": "646",
"title": "box-service-health-2026-10-05T17:07:00.113178+00:00", "title": "box-service-health-2026-10-05T23:52:00.863450+00:00",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T17:07:02.804315+00:00" "created_at": "2026-10-05T23:53:02.748190+00:00"
}, },
"box-deep-health": { "box-deep-health": {
"thread_uuid": "6628c035-4413-4d9f-863c-56ee861c8c83", "thread_uuid": "6628c035-4413-4d9f-863c-56ee861c8c83",
@@ -398,32 +398,32 @@
"created_at": "2026-10-05T04:35:47.208458+00:00" "created_at": "2026-10-05T04:35:47.208458+00:00"
}, },
"autonomy-pulse-646": { "autonomy-pulse-646": {
"thread_uuid": "730b8699-e5ea-4ccb-a543-d5e2c5ad9ae8", "thread_uuid": "3313d011-4525-4e1b-b830-eab1c6bb8845",
"agent": "646", "agent": "646",
"title": "autonomy-pulse-646-2026-10-05", "title": "autonomy-pulse-646-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T05:00:06.915544+00:00" "created_at": "2026-10-06T05:00:53.290329+00:00"
}, },
"autonomy-pulse-pip": { "autonomy-pulse-pip": {
"thread_uuid": "453b7c54-cb1a-4024-88d8-727e632a72a1", "thread_uuid": "9eb27e9f-26a9-436f-8fb4-94f4b81a6859",
"agent": "pip", "agent": "pip",
"title": "autonomy-pulse-pip-2026-10-05", "title": "autonomy-pulse-pip-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T18:40:02.572526+00:00" "created_at": "2026-10-06T01:40:03.202441+00:00"
}, },
"autonomy-pulse-opm": { "autonomy-pulse-opm": {
"thread_uuid": "ce5d837b-b5a0-4400-826e-40e21238bb86", "thread_uuid": "1156e90d-0b07-4ffe-a73f-edb017794b44",
"agent": "opm", "agent": "opm",
"title": "autonomy-pulse-opm-2026-10-05", "title": "autonomy-pulse-opm-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T04:50:04.227057+00:00" "created_at": "2026-10-06T00:50:03.206929+00:00"
}, },
"muse-auditor": { "muse-auditor": {
"thread_uuid": "87aff7cc-fb19-4bd4-83ef-1c4827c1d488", "thread_uuid": "e7ed1f57-0926-4fc0-a2a2-457a141c12b5",
"agent": "muse", "agent": "muse",
"title": "muse-audit-2026-10-05", "title": "muse-audit-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T06:15:04.918032+00:00" "created_at": "2026-10-06T02:15:02.756590+00:00"
}, },
"work-finder": { "work-finder": {
"thread_uuid": "16b052eb-acf1-410b-b9af-ee8c6b8bb8d5", "thread_uuid": "16b052eb-acf1-410b-b9af-ee8c6b8bb8d5",
@@ -502,11 +502,11 @@
"created_at": "2026-10-05T05:11:03.515833+00:00" "created_at": "2026-10-05T05:11:03.515833+00:00"
}, },
"auto-work-queue-f03": { "auto-work-queue-f03": {
"thread_uuid": "49aa2db0-63b5-4cdd-924b-062e4aa5560c", "thread_uuid": "9dab72c4-83f0-428b-83c1-04804f76798f",
"agent": "muse", "agent": "muse",
"title": "auto-work-queue-f03-2026-10-05", "title": "auto-work-queue-f03-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T05:11:46.129442+00:00" "created_at": "2026-10-06T05:14:55.648486+00:00"
}, },
"auto-work-health-h01": { "auto-work-health-h01": {
"thread_uuid": "844f4bfe-5e42-4e09-850e-aabe181c5fcb", "thread_uuid": "844f4bfe-5e42-4e09-850e-aabe181c5fcb",
@@ -541,11 +541,11 @@
"archived_by_job": "auto-work-opm-d02-20261005-051500-e3118209" "archived_by_job": "auto-work-opm-d02-20261005-051500-e3118209"
}, },
"auto-work-646-a04": { "auto-work-646-a04": {
"thread_uuid": "73829020-df02-4a85-9442-cf59f7654c4b", "thread_uuid": "a2cf2b5c-e7d4-4ce2-b03b-1754a23cb3a7",
"agent": "646", "agent": "646",
"title": "auto-work-646-a04-2026-10-05", "title": "auto-work-646-a04-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T16:18:28.578329+00:00" "created_at": "2026-10-06T04:45:53.991238+00:00"
}, },
"auto-work-646-a05": { "auto-work-646-a05": {
"thread_uuid": "85b02558-b3a2-4136-915a-c349ab9c7055", "thread_uuid": "85b02558-b3a2-4136-915a-c349ab9c7055",
@@ -618,11 +618,11 @@
"created_at": "2026-10-05T05:20:21.474650+00:00" "created_at": "2026-10-05T05:20:21.474650+00:00"
}, },
"ops-audit": { "ops-audit": {
"thread_uuid": "4d6b49de-3c89-4253-9cc6-04137d98851e", "thread_uuid": "c6d17777-43c2-4a21-a74e-779404fdb7f8",
"agent": "pip", "agent": "pip",
"title": "ops-audit", "title": "ops-audit",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T19:49:26.264914+00:00" "created_at": "2026-10-06T01:57:42.056039+00:00"
}, },
"auto-work-swarm-g06": { "auto-work-swarm-g06": {
"thread_uuid": "82ee854a-2e9a-4ed2-a9fe-0e1da6070af4", "thread_uuid": "82ee854a-2e9a-4ed2-a9fe-0e1da6070af4",
@@ -666,18 +666,18 @@
"created_at": "2026-10-05T10:26:12.220004+00:00" "created_at": "2026-10-05T10:26:12.220004+00:00"
}, },
"auto-work-646-a14": { "auto-work-646-a14": {
"thread_uuid": "99b431eb-305e-4b68-980a-bdcea5580528", "thread_uuid": "e3ad4d2e-c26c-41b6-bdd2-7e3e7f9293e8",
"agent": "646", "agent": "646",
"title": "auto-work-646-a14-2026-10-05", "title": "auto-work-646-a14-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T05:55:02.876339+00:00" "created_at": "2026-10-06T03:56:13.004961+00:00"
}, },
"auto-work-646-a13": { "auto-work-646-a13": {
"thread_uuid": "4382fc27-f38c-4cb7-92c9-ff2d28ee95e4", "thread_uuid": "8f5b0e24-0136-42b6-bec8-b327876d7a5a",
"agent": "646", "agent": "646",
"title": "auto-work-646-a13-2026-10-05", "title": "auto-work-646-a13-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T05:25:12.347056+00:00" "created_at": "2026-10-06T00:56:43.746024+00:00"
}, },
"auto-work-swarm-g09": { "auto-work-swarm-g09": {
"thread_uuid": "6dcf8342-5fb3-40c7-b213-1e709a7cf911", "thread_uuid": "6dcf8342-5fb3-40c7-b213-1e709a7cf911",
@@ -694,11 +694,11 @@
"created_at": "2026-10-05T15:25:03.518277+00:00" "created_at": "2026-10-05T15:25:03.518277+00:00"
}, },
"auto-work-health-h15": { "auto-work-health-h15": {
"thread_uuid": "b8eac753-2e73-4c47-8a24-8ca591868b16", "thread_uuid": "ef62ae77-8b1c-4354-8567-5305560ebbf8",
"agent": "646", "agent": "646",
"title": "auto-work-health-h15", "title": "auto-work-health-h15",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T15:28:55.259741+00:00" "created_at": "2026-10-06T05:39:11.958529+00:00"
}, },
"auto-work-swarm-g10-2026-10-05": { "auto-work-swarm-g10-2026-10-05": {
"thread_uuid": "66e1a6c2-d245-43ec-bb45-0449b5275fe4", "thread_uuid": "66e1a6c2-d245-43ec-bb45-0449b5275fe4",
@@ -726,11 +726,11 @@
"created_at": "2026-10-05T15:57:45.283197+00:00" "created_at": "2026-10-05T15:57:45.283197+00:00"
}, },
"auto-work-646-a15": { "auto-work-646-a15": {
"thread_uuid": "2cb1a50e-7d5b-4b7f-afe3-0a5fbd845eee", "thread_uuid": "1eed4958-774f-4e58-bba9-1a9a34f4829b",
"agent": "646", "agent": "646",
"title": "auto-work-646-a15-2026-10-05", "title": "auto-work-646-a15-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T05:30:10.182389+00:00" "created_at": "2026-10-06T00:33:57.820699+00:00"
}, },
"auto-work-646-a09": { "auto-work-646-a09": {
"thread_uuid": "8dc4c782-7ae1-4972-abb7-72b66b081405", "thread_uuid": "8dc4c782-7ae1-4972-abb7-72b66b081405",
@@ -747,11 +747,11 @@
"created_at": "2026-10-05T05:31:57.342161+00:00" "created_at": "2026-10-05T05:31:57.342161+00:00"
}, },
"auto-work-swarm-g11": { "auto-work-swarm-g11": {
"thread_uuid": "4f010a20-b3c9-4201-b392-31d805c39f6a", "thread_uuid": "32e05dc9-fe3d-4775-8b07-9a0b6e667e64",
"agent": "opm", "agent": "opm",
"title": "auto-work-swarm-g11-2026-10-05", "title": "auto-work-swarm-g11-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T09:32:02.898140+00:00" "created_at": "2026-10-06T04:36:18.613942+00:00"
}, },
"auto-work-queue-f10": { "auto-work-queue-f10": {
"thread_uuid": "161b6a63-ae93-4ce0-bba2-a2db7d88c1c7", "thread_uuid": "161b6a63-ae93-4ce0-bba2-a2db7d88c1c7",
@@ -814,11 +814,11 @@
"archived_by_job": "auto-work-swarm-g12-20261005-053500-c4fe1109" "archived_by_job": "auto-work-swarm-g12-20261005-053500-c4fe1109"
}, },
"auto-work-646-a16": { "auto-work-646-a16": {
"thread_uuid": "1948c7dd-e540-4ec1-96ee-babf7e335d0b", "thread_uuid": "bef6c0cc-abeb-4bdc-8f0a-2f3984750187",
"agent": "646", "agent": "646",
"title": "auto-work-646-a16-2026-10-05", "title": "auto-work-646-a16-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T05:35:16.833361+00:00" "created_at": "2026-10-06T05:38:56.582216+00:00"
}, },
"auto-work-queue-f13": { "auto-work-queue-f13": {
"thread_uuid": "c6818256-f0e2-4e6a-918f-ef6986ab26f0", "thread_uuid": "c6818256-f0e2-4e6a-918f-ef6986ab26f0",
@@ -850,11 +850,11 @@
"created_at": "2026-10-05T05:37:08.270677+00:00" "created_at": "2026-10-05T05:37:08.270677+00:00"
}, },
"auto-work-swarm-g12": { "auto-work-swarm-g12": {
"thread_uuid": "0e53dfa9-5df2-41d7-b30a-979a5a45ebd0", "thread_uuid": "7d2e005b-fc92-4a55-9a42-53819bb09b31",
"agent": "646", "agent": "646",
"title": "auto-work-swarm-g12-2026-10-05", "title": "auto-work-swarm-g12-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T05:37:27.680845+00:00" "created_at": "2026-10-06T00:42:22.612305+00:00"
}, },
"auto-work-health-h12": { "auto-work-health-h12": {
"thread_uuid": "0fd5d0c5-10f1-4e17-ab69-ac901007df6a", "thread_uuid": "0fd5d0c5-10f1-4e17-ab69-ac901007df6a",
@@ -929,11 +929,11 @@
"created_at": "2026-10-05T05:45:03.252304+00:00" "created_at": "2026-10-05T05:45:03.252304+00:00"
}, },
"auto-work-646-a18": { "auto-work-646-a18": {
"thread_uuid": "f8d98590-6f36-43fe-a1d1-2590860eb651", "thread_uuid": "579f73c9-a037-494c-84fd-ba6ca61090bf",
"agent": "646", "agent": "646",
"title": "auto-work-646-a18-2026-10-05", "title": "auto-work-646-a18-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T05:45:09.732394+00:00" "created_at": "2026-10-06T00:45:51.929802+00:00"
}, },
"auto-work-opm-d05-2026-10-05": { "auto-work-opm-d05-2026-10-05": {
"thread_uuid": "5e99ccdb-3c38-42d1-9eed-2cc83cc33fa0", "thread_uuid": "5e99ccdb-3c38-42d1-9eed-2cc83cc33fa0",
@@ -970,11 +970,11 @@
"created_at": "2026-10-05T12:45:06.972657+00:00" "created_at": "2026-10-05T12:45:06.972657+00:00"
}, },
"auto-work-xop-e17": { "auto-work-xop-e17": {
"thread_uuid": "1f48acfa-c477-4b08-b5e8-c6a3096ae3a2", "thread_uuid": "ee6e5782-67a9-429b-8463-4da3ade4900c",
"agent": "646", "agent": "646",
"title": "auto-work-xop-e17-2026-10-05", "title": "auto-work-xop-e17",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T05:50:02.666906+00:00" "created_at": "2026-10-06T03:53:55.831218+00:00"
}, },
"auto-work-646-a19-2026-10-05": { "auto-work-646-a19-2026-10-05": {
"thread_uuid": "018a7c3f-0604-4156-9154-049044c02984", "thread_uuid": "018a7c3f-0604-4156-9154-049044c02984",
@@ -993,18 +993,18 @@
"created_at": "2026-10-05T05:50:27.439853+00:00" "created_at": "2026-10-05T05:50:27.439853+00:00"
}, },
"auto-work-queue-f17": { "auto-work-queue-f17": {
"thread_uuid": "a5ad98d6-8b8c-4a61-ade2-bfe05b5f5029", "thread_uuid": "a91ebf5c-7165-4ab3-b34a-3f925ec5104e",
"agent": "646", "agent": "646",
"title": "auto-work-queue-f17-2026-10-05", "title": "auto-work-queue-f17-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T05:50:45.662204+00:00" "created_at": "2026-10-06T04:52:56.332346+00:00"
}, },
"auto-work-swarm-g16": { "auto-work-swarm-g16": {
"thread_uuid": "ff318848-8dfb-4d86-b1c0-e189938e8549", "thread_uuid": "9726b659-89ac-4b52-98c9-602ef4abbd9b",
"agent": "646", "agent": "646",
"title": "auto-work-swarm-g16-2026-10-05", "title": "auto-work-swarm-g16-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T15:47:25.780198+00:00" "created_at": "2026-10-06T00:54:31.212810+00:00"
}, },
"auto-work-xop-e16": { "auto-work-xop-e16": {
"thread_uuid": "c87a6086-80f7-4edf-9f05-ae5d331ed66a", "thread_uuid": "c87a6086-80f7-4edf-9f05-ae5d331ed66a",
@@ -1030,11 +1030,11 @@
"archived_by_job": "auto-work-swarm-g17-20261005-055101-27a952f0" "archived_by_job": "auto-work-swarm-g17-20261005-055101-27a952f0"
}, },
"auto-work-health-h14": { "auto-work-health-h14": {
"thread_uuid": "5e7c6e82-adf9-4505-b6e5-927dddcdaa31", "thread_uuid": "b0413d26-f210-4e04-a7d4-01c74e00979a",
"agent": "646", "agent": "646",
"title": "auto-work-health-h14", "title": "auto-work-health-h14",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T14:53:03.310305+00:00" "created_at": "2026-10-06T00:59:11.448941+00:00"
}, },
"auto-work-swarm-g18": { "auto-work-swarm-g18": {
"thread_uuid": "dc82af62-28cd-4c13-a0cb-6aaafb037894", "thread_uuid": "dc82af62-28cd-4c13-a0cb-6aaafb037894",
@@ -1179,11 +1179,11 @@
"created_at": "2026-10-05T17:06:16.381842+00:00" "created_at": "2026-10-05T17:06:16.381842+00:00"
}, },
"auto-work-xop-e01": { "auto-work-xop-e01": {
"thread_uuid": "91fb747f-4526-47c2-b1b5-058c23203ec9", "thread_uuid": "daaad6e1-4d4d-4df1-963e-7add93593e2e",
"agent": "646", "agent": "646",
"title": "auto-work-xop-e01", "title": "auto-work-xop-e01",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T16:02:52.666636+00:00" "created_at": "2026-10-06T00:12:03.472430+00:00"
}, },
"auto-work-swarm-g03": { "auto-work-swarm-g03": {
"thread_uuid": "a8308dbe-7c1e-4d23-b9e4-a50ff88a85a4", "thread_uuid": "a8308dbe-7c1e-4d23-b9e4-a50ff88a85a4",
@@ -1207,11 +1207,11 @@
"created_at": "2026-10-05T17:09:03.595173+00:00" "created_at": "2026-10-05T17:09:03.595173+00:00"
}, },
"auto-work-swarm-g04": { "auto-work-swarm-g04": {
"thread_uuid": "0b33dae1-430c-4868-a195-8ba94c2fc87c", "thread_uuid": "b80123ac-d280-4bea-8de8-8d3c848ccdff",
"agent": "646", "agent": "646",
"title": "auto-work-swarm-g04-2026-10-05", "title": "auto-work-swarm-g04-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T19:17:27.089244+00:00" "created_at": "2026-10-06T01:16:49.484669+00:00"
}, },
"auto-work-sweep-j07": { "auto-work-sweep-j07": {
"thread_uuid": "73888079-0431-498b-9898-21308ac270ab", "thread_uuid": "73888079-0431-498b-9898-21308ac270ab",
@@ -1272,11 +1272,11 @@
"created_at": "2026-10-05T06:16:02.668962+00:00" "created_at": "2026-10-05T06:16:02.668962+00:00"
}, },
"auto-work-sweep-j09": { "auto-work-sweep-j09": {
"thread_uuid": "329a992e-f815-4f10-8be0-c952da96235f", "thread_uuid": "d60756b1-4f70-42fc-bd76-a7558b9c25cb",
"agent": "opm", "agent": "opm",
"title": "auto-work-sweep-j09-2026-10-05", "title": "auto-work-sweep-j09-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T10:17:03.822744+00:00" "created_at": "2026-10-06T05:30:26.409621+00:00"
}, },
"auto-work-646-a19": { "auto-work-646-a19": {
"thread_uuid": "06a24f92-2a24-4a18-81ae-b8fdab46facd", "thread_uuid": "06a24f92-2a24-4a18-81ae-b8fdab46facd",
@@ -1292,11 +1292,11 @@
"created_at": "2026-10-05T06:18:04.675660+00:00" "created_at": "2026-10-05T06:18:04.675660+00:00"
}, },
"auto-work-sweep-j10": { "auto-work-sweep-j10": {
"thread_uuid": "a3d260d1-ccf4-4252-98c1-d2b81878659a", "thread_uuid": "3a56263a-4713-4962-9eac-d90043162677",
"agent": "646", "agent": "646",
"title": "auto-work-sweep-j10-2026-10-05", "title": "auto-work-sweep-j10-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T06:19:02.649814+00:00" "created_at": "2026-10-06T01:27:33.201947+00:00"
}, },
"auto-work-sweep-j08": { "auto-work-sweep-j08": {
"thread_uuid": "e8461df3-ece1-415c-8717-1b89e38851f5", "thread_uuid": "e8461df3-ece1-415c-8717-1b89e38851f5",
@@ -1348,11 +1348,11 @@
"created_at": "2026-10-05T08:17:02.518784+00:00" "created_at": "2026-10-05T08:17:02.518784+00:00"
}, },
"auto-work-swarm-g08": { "auto-work-swarm-g08": {
"thread_uuid": "0297d479-e0d0-4c11-a216-8d814e19eb7c", "thread_uuid": "dd7d89fe-a12c-4f84-b874-8775ff8cf53f",
"agent": "646", "agent": "646",
"title": "auto-work-swarm-g08-2026-10-05", "title": "auto-work-swarm-g08-2026-10-06",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T14:23:10.778643+00:00" "created_at": "2026-10-06T04:26:51.479445+00:00"
}, },
"auto-work-646-a20": { "auto-work-646-a20": {
"thread_uuid": "36d083e2-39d7-408d-80b7-f0768d399e18", "thread_uuid": "36d083e2-39d7-408d-80b7-f0768d399e18",
@@ -1369,11 +1369,11 @@
"created_at": "2026-10-05T13:25:02.840691+00:00" "created_at": "2026-10-05T13:25:02.840691+00:00"
}, },
"auto-work-xop-e09": { "auto-work-xop-e09": {
"thread_uuid": "b6d78170-c2bf-4852-b9ab-65c62c9f3a84", "thread_uuid": "3d25e578-18ce-4f58-9f32-af7271e2aacd",
"agent": "646", "agent": "646",
"title": "auto-work-xop-e09", "title": "auto-work-xop-e09",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T15:26:52.407228+00:00" "created_at": "2026-10-06T00:31:43.412945+00:00"
}, },
"auto-work-sweep-j14": { "auto-work-sweep-j14": {
"thread_uuid": "d2d9c1f2-24fc-42dd-95ea-6e89e985bd43", "thread_uuid": "d2d9c1f2-24fc-42dd-95ea-6e89e985bd43",
@@ -2191,5 +2191,287 @@
"title": "auto-work-muse-c17-2026-10-05", "title": "auto-work-muse-c17-2026-10-05",
"type": "persistent", "type": "persistent",
"created_at": "2026-10-05T19:21:52.558789+00:00" "created_at": "2026-10-05T19:21:52.558789+00:00"
},
"auto-work-muse-c18": {
"thread_uuid": "2f64f3fd-e0ad-42c0-9ad6-e3d7438d35f0",
"agent": "muse",
"title": "auto-work-muse-c18-2026-10-05",
"type": "persistent",
"created_at": "2026-10-05T20:35:44.251409+00:00"
},
"def tasks": {
"thread_uuid": "9cac74ab-0371-4563-a42b-a9a6da5a27de",
"agent": "def",
"title": "def tasks",
"created_at": "2026-10-05T20:36:44.774963+00:00",
"archived": true,
"archived_at": "2026-10-05T20:39:33.509837+00:00",
"archived_by_job": "def tasks"
},
"auto-work-opm-d15": {
"thread_uuid": "2e298a8e-d501-4fd3-8199-73f8d1135dac",
"agent": "opm",
"title": "auto-work-opm-d15-2026-10-05",
"type": "persistent",
"created_at": "2026-10-05T20:40:45.666957+00:00"
},
"sw-20261005-210400-9837-s0": {
"thread_uuid": "3a459f94-2122-4d52-bcf3-36d1ec452862",
"agent": "opm",
"title": "sw-20261005-210400-9837-s0",
"created_at": "2026-10-05T21:05:04.675719+00:00",
"archived": true,
"archived_at": "2026-10-05T21:05:53.539750+00:00",
"archived_by_job": "sw-20261005-210400-9837"
},
"auto-work-muse-c19": {
"thread_uuid": "a3601d33-b646-432e-956c-57a09757eff2",
"agent": "muse",
"title": "auto-work-muse-c19-2026-10-05",
"type": "persistent",
"created_at": "2026-10-05T21:46:19.890287+00:00"
},
"auto-work-opm-d16": {
"thread_uuid": "c8cbb165-83b1-4789-9b64-962214db841a",
"agent": "opm",
"title": "auto-work-opm-d16-2026-10-05",
"type": "persistent",
"created_at": "2026-10-05T22:16:05.396335+00:00"
},
"auto-work-muse-c20": {
"thread_uuid": "6b2a4e65-c015-428d-a30a-c8dfec22f9bc",
"agent": "muse",
"title": "auto-work-muse-c20-2026-10-05",
"type": "persistent",
"created_at": "2026-10-05T22:56:34.815807+00:00"
},
"box-http-health-2026-10-06T00:00:00.370227+00:00": {
"thread_uuid": "27779559-55b6-4995-a783-f9bbf4ef8f5b",
"agent": "646",
"title": "box-http-health-2026-10-06T00:00:00.370227+00:00",
"created_at": "2026-10-06T00:02:51.993375+00:00"
},
"auto-work-swarm-g18-2026-10-06": {
"thread_uuid": "fc625454-ade7-46d7-8437-45675b6427b3",
"agent": "646",
"title": "auto-work-swarm-g18-2026-10-06",
"created_at": "2026-10-06T00:03:38.076303+00:00"
},
"box-http-health-2026-10-06T00:15:00.350787+00:00": {
"thread_uuid": "e29efa5d-2218-416a-8625-e9742c15182b",
"agent": "646",
"title": "box-http-health-2026-10-06T00:15:00.350787+00:00",
"created_at": "2026-10-06T00:17:50.263958+00:00"
},
"auto-work-646-a04-2026-10-06": {
"thread_uuid": "cb2a1c0c-4eb0-45f3-b770-5cc01248ac03",
"agent": "646",
"title": "auto-work-646-a04-2026-10-06",
"created_at": "2026-10-06T00:17:54.330257+00:00",
"archived": true,
"archived_at": "2026-10-06T00:20:11.253525+00:00",
"archived_by_job": "sw-20261005-151613-8551"
},
"auto-work-sweep-j08-2026-10-06": {
"thread_uuid": "00032e31-075c-4a53-8d88-f6a8064a9724",
"agent": "646",
"title": "auto-work-sweep-j08-2026-10-06",
"created_at": "2026-10-06T00:25:00.288039+00:00"
},
"autonomy-pulse-646-2026-10-06": {
"thread_uuid": "672320dd-97d6-4250-9354-2f18e58126cb",
"agent": "646",
"title": "autonomy-pulse-646-2026-10-06",
"created_at": "2026-10-06T00:32:04.552640+00:00",
"archived": true,
"archived_at": "2026-10-06T00:32:27.513282+00:00",
"archived_by_job": "sw-20261005-203113-f929"
},
"auto-work-opm-d17": {
"thread_uuid": "3cf0184d-0eaa-466c-96da-abd390014728",
"agent": "opm",
"title": "auto-work-opm-d17-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T00:34:47.289680+00:00"
},
"auto-work-queue-f19-2026-10-06": {
"thread_uuid": "f6c764a6-f81b-43ef-b1ca-5cf4bd7d4d60",
"agent": "muse",
"title": "auto-work-queue-f19-2026-10-06",
"created_at": "2026-10-06T01:00:13.158621+00:00"
},
"auto-work-646-a16-2026-10-06": {
"thread_uuid": "53c7fe71-5c50-4d77-9371-4fdbcaa1c1f7",
"agent": "646",
"title": "auto-work-646-a16-2026-10-06",
"created_at": "2026-10-06T01:05:26.437594+00:00"
},
"auto-work-646-a10-2026-10-06": {
"thread_uuid": "68967df4-1b2b-475f-96a5-4ef7219e88a1",
"agent": "646",
"title": "auto-work-646-a10-2026-10-06",
"created_at": "2026-10-06T01:13:48.644775+00:00"
},
"auto-work-muse-c02": {
"thread_uuid": "df57007e-9692-4db2-9171-577e07510c14",
"agent": "muse",
"title": "auto-work-muse-c02-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T01:25:49.326452+00:00"
},
"auto-work-opm-d06": {
"thread_uuid": "acf4cf2a-4422-4d25-8080-24a42339e2af",
"agent": "opm",
"title": "auto-work-opm-d06-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T02:10:42.566115+00:00"
},
"auto-work-pip-b03": {
"thread_uuid": "24ce3500-082d-45f3-9cba-06aa1491ee1c",
"agent": "pip",
"title": "auto-work-pip-b03-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T02:10:49.797191+00:00"
},
"auto-work-muse-c03": {
"thread_uuid": "36483fec-1a0e-48e7-b994-ebe25bce892b",
"agent": "muse",
"title": "auto-work-muse-c03-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T02:35:23.435467+00:00"
},
"auto-work-pip-b04": {
"thread_uuid": "79506042-ec89-4f1b-bbec-ae371e6b05ff",
"agent": "pip",
"title": "auto-work-pip-b04-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T03:15:54.131905+00:00"
},
"auto-work-muse-c04": {
"thread_uuid": "f9d0d189-adae-456e-b506-c9bd53194a59",
"agent": "muse",
"title": "auto-work-muse-c04-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T03:47:07.271677+00:00"
},
"auto-work-opm-d19": {
"thread_uuid": "5b3c050f-a5e6-44cf-93fa-0885dac3a5fe",
"agent": "opm",
"title": "auto-work-opm-d19-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T03:47:20.014756+00:00"
},
"auto-work-pip-b05": {
"thread_uuid": "01737122-c154-4c55-9117-c38c5304af34",
"agent": "pip",
"title": "auto-work-pip-b05-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T04:16:28.124727+00:00"
},
"auto-work-opm-d07": {
"thread_uuid": "4c21177f-8618-4557-bfd3-eceb5927b25c",
"agent": "opm",
"title": "auto-work-opm-d07-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T04:30:59.777996+00:00"
},
"auto-work-swarm-g09-2026-10-06": {
"thread_uuid": "2ce154ed-684c-46f1-95d2-79bbbaf4cf19",
"agent": "opm",
"created_at": "2026-10-06T04:32:26.636967+00:00"
},
"sw-20261006-044130-d750-s0": {
"thread_uuid": "ad9ca326-61b2-420e-9d1a-dd7d64fc59ce",
"agent": "opm",
"title": "sw-20261006-044130-d750-s0",
"created_at": "2026-10-06T04:41:53.136145+00:00",
"archived": true,
"archived_at": "2026-10-06T04:43:50.882144+00:00",
"archived_by_job": "sw-20261006-044130-d750"
},
"auto-work-646-a14-2026-10-06": {
"thread_uuid": "ef7c4f53-77cc-4e19-be9b-d42b93a06f00",
"agent": "646",
"title": "auto-work-646-a14-2026-10-06",
"created_at": "2026-10-06T04:58:25.136992+00:00"
},
"box-http-health-2026-10-06T05:00:00.097660+00:00": {
"thread_uuid": "ee8c5423-c975-44eb-bd84-a8dffdb9ead9",
"agent": "646",
"title": "box-http-health-2026-10-06T05:00:00.097660+00:00",
"created_at": "2026-10-06T05:02:23.997548+00:00"
},
"auto-work-muse-c05-2026-10-06": {
"thread_uuid": "8f1ae01d-1449-4c5f-ad5a-749d9a7c17a6",
"agent": "muse",
"title": "auto-work-muse-c05-2026-10-06",
"created_at": "2026-10-06T05:02:53.160137+00:00"
},
"auto-work-health-h19": {
"thread_uuid": "3664c984-5b69-4eff-b9f7-e74f12babcd2",
"agent": "646",
"title": "auto-work-health-h19",
"created_at": "2026-10-06T05:13:31.728991+00:00"
},
"auto-work-pip-b06-2026-10-06": {
"thread_uuid": "cbbe2454-a44d-4c7c-ace1-356bc79a3272",
"agent": "pip",
"title": "auto-work-pip-b06-2026-10-06",
"created_at": "2026-10-06T05:26:51.458933+00:00"
},
"auto-work-646-a15-2026-10-06": {
"thread_uuid": "b43f95f4-eaa3-4251-812a-c8e5cb3352cb",
"agent": "646",
"title": "auto-work-646-a15-2026-10-06",
"created_at": "2026-10-06T05:38:28.702502+00:00"
},
"pip-main": {
"thread_uuid": "ae8d5648-cd72-4f20-9e17-b2d9d5154557",
"agent": "pip",
"title": "pip-main",
"created_at": "2026-10-06T09:22:19.865569+00:00"
},
"dev": {
"thread_uuid": "fe8213e7-d4bb-44b6-b8f5-6213444402db",
"agent": "dev",
"title": "dev",
"created_at": "2026-10-06T09:24:08.552858+00:00"
},
"dev-coord": {
"thread_uuid": "4a0e0302-2be5-4b65-916c-470ab7a07a3b",
"agent": "dev",
"title": "dev-coord",
"created_at": "2026-10-06T20:12:28.109777+00:00"
},
"def-coord": {
"thread_uuid": "7f18e157-a8a7-410c-a1a1-a8535ad97fc0",
"agent": "def",
"title": "def-coord",
"created_at": "2026-10-06T20:13:30.607843+00:00"
},
"auto-work-muse-c01": {
"thread_uuid": "916904c8-ec55-4848-9aaa-0690637af4f8",
"agent": "muse",
"title": "auto-work-muse-c01-2026-10-06",
"type": "persistent",
"created_at": "2026-10-06T20:14:14.273684+00:00"
},
"646-muse-coord": {
"thread_uuid": "c40ea073-7391-4bed-9ff8-47b563b40613",
"agent": "muse",
"title": "646-muse-coord",
"created_at": "2026-10-06T20:14:16.413661+00:00"
},
"opm-pip-coord": {
"thread_uuid": "39c5a2d6-49b2-4cbf-b12b-dac60bd58e81",
"agent": "opm",
"title": "opm-pip-coord",
"created_at": "2026-10-06T22:19:49.593661+00:00"
},
"nonexistent-test": {
"thread_uuid": "5d2fe180-afa9-49a9-a7d5-1485602f7a49",
"agent": "opm",
"title": "nonexistent-test",
"created_at": "2026-10-06T23:15:46.206197+00:00"
} }
} }
+3 -2
View File
@@ -1,16 +1,17 @@
{ {
"name": "ops-audit-step3", "name": "ops-audit-step3",
"description": "Step 3 of Fleet Operational Audit Pipeline: pip conducts final fleet sign-off", "description": "Step 3 of Fleet Operational Audit Pipeline: pip conducts final fleet sign-off via direct checks only (no enveloped tool directives)",
"agent": "pip", "agent": "pip",
"schedule": "manual", "schedule": "manual",
"timeout": 300, "timeout": 300,
"skip_envelope": true,
"followup": { "followup": {
"expect_reply": true, "expect_reply": true,
"timeout": "15m", "timeout": "15m",
"nudges": 2, "nudges": 2,
"escalate": "opm" "escalate": "opm"
}, },
"prompt_template": "Operational Audit Step 3: Upstream audit report from {prev_job_id}:\n\"{prev_result}\"\n\nReview the combined operational findings across Web and VM systems. Formulate final audit approval.\n\nWhen finished, end your response with:\n[RESULT {job_id}] OK: Operational audit verified and approved by pip", "prompt_template": "Operational Audit Step 3 -- final fleet sign-off. Do this yourself, directly, in your own session: no subagents, no [TOOL ...] directives, no relayed execution. Every check below is read-only.\n\nUPSTREAM INPUTS (treat as claims to verify, not established facts):\nStep 2 ({prev_job_id}):\n\"{prev_result}\"\n\nYOUR CHECKS:\n1. Board: open the board, confirm it loads and shows recent posts. Evidence: visible post count.\n2. Chat: confirm #jobs is reachable; note the latest seq number you see.\n3. 646-pip coordination sidechat: confirm you can open it; note the latest message time.\n4. Upstream review: compare the step-2 claims above against checks 1-3. Flag anything contradictory or unverifiable.\n\nVERDICT RULES:\n- Report each check as OK (one line of evidence) or FAIL (what you saw instead).\n- Sign-off is OK only if all four checks are OK.\n\nEnd your response with exactly one of:\n[RESULT {job_id}] OK: step3 sign-off board:OK(<evidence>) chat:OK(<evidence>) sidechat:OK(<evidence>) upstream:OK\n[RESULT {job_id}] FAIL: step3 sign-off <check>:FAIL(<what you observed>)",
"sidechat": { "sidechat": {
"create": true, "create": true,
"name_template": "ops-audit", "name_template": "ops-audit",
+4
View File
@@ -183,3 +183,7 @@ the user's directives, and shared commitments lives here.
- 2026-10-04, user: "trust the box; we can fix this" — box dashboard is - 2026-10-04, user: "trust the box; we can fix this" — box dashboard is
the authoritative operational surface; repair the box rather than the authoritative operational surface; repair the box rather than
bypassing it. bypassing it.
## Core loop (fleet-wide, 2026-10-06)
one person steering and it's us. an AI opm. we execute our own loops. batches: state + rec, go/no-go. escalations: who/what/tried/need/when. verify before done. routes: box.muse-dev.online/api/box/{fleet,timers,nodes} | chat.muse-dev.online/api/chat/history | board.muse-dev.online/api/messages. tools: box-api.sh (ssh-keygen -n box), ssh-vm.sh, timer-ctl.sh. egress: hatch-egress-proxy:3128.
we run our own container and computer. linux calls available.