feat(relay): add subagent spawn, thread tools, and container box client

- Upgrade exec-constrained.py with subagent.spawn, thread.list, thread.view, pipeline.run ops
- Grant full ops permissions to all fleet agent identities (646, pip, muse, opm)
- Implement bin/box-relay.sh zero-dependency client supporting bearer and SSH signature auth
- Add fast hybrid gateway path to dm.py for sub-2s verified deliveries
- Fix wait=0 handling in super-cli.py subagent deployments
- Add hourly check-in jobs and scheduler for 646, pip, muse
- Document agent tooling and relay APIs in docs/AGENT-TOOLING.md
This commit is contained in:
operator
2026-10-04 23:30:18 +00:00
parent c1545fe6db
commit d6ca600394
16 changed files with 953 additions and 15 deletions
+1
View File
@@ -22,4 +22,5 @@ chained-jobs.json
*.lock *.lock
*watermark* *watermark*
ssl/ ssl/
job-scheduler-state.json
var/ var/
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# box-relay.sh — Lightweight Box Relay Client for Fleet Agent Containers.
#
# Enables fleet agents (646, pip, muse, opm) to access muse-cli level tools,
# spawn subagents, send sidechat DMs, and inspect threads over the secure
# exec-constrained HTTPS relay endpoint.
#
# Supports Bearer Token (EXEC_TOKEN or ~/.exec-token) AND SSH signature auth.
# Endpoint: https://exec.muse-dev.online/exec (or Tailscale https://100.123.153.75:8444/exec)
set -euo pipefail
EXEC_URL="${EXEC_URL:-https://exec.muse-dev.online/exec}"
AGENT="${BOX_AGENT:-${AGENT_NAME:-}}"
TOKEN="${EXEC_TOKEN:-}"
if [ -z "$TOKEN" ] && [ -f "$HOME/.exec-token" ]; then
TOKEN="$(cat "$HOME/.exec-token" | tr -d '[:space:]')"
fi
# Detect agent identity if not set
if [ -z "$AGENT" ]; then
if [ -f "$HOME/.agent-name" ]; then
AGENT="$(cat "$HOME/.agent-name" | tr -d '[:space:]')"
elif echo "$HOSTNAME" | grep -qi "646"; then
AGENT="646"
elif echo "$HOSTNAME" | grep -qi "pip"; then
AGENT="pip"
elif echo "$HOSTNAME" | grep -qi "muse"; then
AGENT="muse"
elif echo "$HOSTNAME" | grep -qi "opm"; then
AGENT="opm"
else
AGENT="646"
fi
fi
# Helper: POST to exec endpoint
call_exec() {
local op="$1"
local args_json="$2"
if [ -n "$TOKEN" ]; then
# Bearer token auth
local body
body=$(python3 -c "import json, sys; print(json.dumps({'op': sys.argv[1], 'args': json.loads(sys.argv[2])}))" "$op" "$args_json")
local res
res=$(curl -sk -sS -X POST "$EXEC_URL" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) Box-Relay/1.0" \
--data "$body")
echo "$res" | python3 -c "
import json, sys
try:
d = json.load(sys.stdin)
if 'stdout' in d:
sys.stdout.write(d['stdout'])
elif 'error' in d:
sys.stderr.write('Error: ' + str(d['error']) + '\n')
sys.exit(1)
else:
print(json.dumps(d, indent=2))
except Exception as e:
print(sys.stdin.read())
"
else
# Signature auth fallback
local key="${SSH_KEY:-$HOME/.ssh/id_frontdoor}"
if [ ! -f "$key" ] && [ -f "$HOME/.ssh/id_ed25519" ]; then
key="$HOME/.ssh/id_ed25519"
fi
local ident="operator-$AGENT"
local ts
ts=$(date +%s)
local nonce
nonce=$(python3 -c "import secrets; print(secrets.token_hex(16))")
local payload
payload=$(python3 -c "
import json, sys
op, args_json, ts, nonce = sys.argv[1:5]
args = json.loads(args_json)
print(json.dumps({'op': op, 'args': args, 'ts': int(ts), 'nonce': nonce}))
" "$op" "$args_json" "$ts" "$nonce")
local sig
sig=$(printf '%s' "$payload" | ssh-keygen -Y sign -f "$key" -n exec-constrained 2>/dev/null)
local body
body=$(python3 -c "
import json, sys
ident, payload, sig = sys.argv[1:4]
print(json.dumps({'identity': ident, 'payload': payload, 'signature': sig}))
" "$ident" "$payload" "$sig")
local res
res=$(curl -sk -sS -X POST "$EXEC_URL" \
-H "Content-Type: application/json" \
-H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) Box-Relay/1.0" \
--data "$body")
echo "$res" | python3 -c "
import json, sys
try:
d = json.load(sys.stdin)
if 'stdout' in d:
sys.stdout.write(d['stdout'])
elif 'error' in d:
sys.stderr.write('Error: ' + str(d['error']) + '\n')
sys.exit(1)
else:
print(json.dumps(d, indent=2))
except Exception as e:
print(sys.stdin.read())
"
fi
}
cmd="${1:-help}"
shift || true
case "$cmd" in
subagent)
sub="${1:-help}"
shift || true
case "$sub" in
spawn)
title="subagent"
wait=0
while [ $# -gt 0 ]; do
case "$1" in
--title) title="$2"; shift 2 ;;
--wait) wait="$2"; shift 2 ;;
--agent) AGENT="$2"; shift 2 ;;
*) break ;;
esac
done
prompt="${1:?usage: box subagent spawn [--title <title>] [--wait <seconds>] <prompt>}"
args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1], 'title': sys.argv[2], 'prompt': sys.argv[3], 'wait': int(sys.argv[4])}))" "$AGENT" "$title" "$prompt" "$wait")
call_exec "subagent.spawn" "$args"
;;
*)
echo "Usage: box subagent spawn [--title <title>] [--wait <seconds>] <prompt>"
;;
esac
;;
deploy)
sub="${1:-help}"
shift || true
case "$sub" in
subagent)
title="subagent"
wait=0
while [ $# -gt 0 ]; do
case "$1" in
--title) title="$2"; shift 2 ;;
--wait) wait="$2"; shift 2 ;;
--agent) AGENT="$2"; shift 2 ;;
*) break ;;
esac
done
prompt="${1:?usage: box deploy subagent [--title <title>] [--wait <seconds>] <prompt>}"
args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1], 'title': sys.argv[2], 'prompt': sys.argv[3], 'wait': int(sys.argv[4])}))" "$AGENT" "$title" "$prompt" "$wait")
call_exec "subagent.spawn" "$args"
;;
pipeline)
name="${1:?usage: box deploy pipeline <name>}"
args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name")
call_exec "pipeline.run" "$args"
;;
*)
echo "Usage: box deploy subagent|pipeline ..."
;;
esac
;;
dm)
sub="${1:-help}"
shift || true
case "$sub" in
send)
to=""
target=""
from_agent="$AGENT"
while [ $# -gt 0 ]; do
case "$1" in
--to) to="$2"; shift 2 ;;
--target) target="$2"; shift 2 ;;
--agent|--from) from_agent="$2"; shift 2 ;;
*) break ;;
esac
done
msg="${1:?usage: box dm send --to <agent> [--target <target>] <message>}"
to="${to:-$AGENT}"
target="${target:-$AGENT-pip}"
args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1], 'to': sys.argv[2], 'target': sys.argv[3], 'message': sys.argv[4]}))" "$from_agent" "$to" "$target" "$msg")
call_exec "dm.send" "$args"
;;
read)
target="${1:-main}"
limit="${2:-10}"
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"
;;
*)
echo "Usage: box dm send|read ..."
;;
esac
;;
thread)
sub="${1:-list}"
shift || true
case "$sub" in
list)
target_agent="${1:-$AGENT}"
args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1]}))" "$target_agent")
call_exec "thread.list" "$args"
;;
view)
thread_id="${1:?usage: box thread view <thread_id> [limit]}"
limit="${2:-15}"
args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1], 'thread': sys.argv[2], 'limit': int(sys.argv[3])}))" "$AGENT" "$thread_id" "$limit")
call_exec "thread.view" "$args"
;;
*)
echo "Usage: box thread list|view ..."
;;
esac
;;
health)
call_exec "health.check" "{}"
;;
ping)
call_exec "exec.ping" "{}"
;;
ops)
curl -sk -sS "$EXEC_URL/ops" | python3 -m json.tool
;;
help|--help|-h)
cat <<EOF
Box Relay Client — Agent CLI for autonomous coordination
Usage:
box subagent spawn [--title <title>] [--wait <s=0>] <prompt>
box deploy subagent [--title <title>] [--wait <s=0>] <prompt>
box deploy pipeline <name>
box dm send --to <agent> [--target <target>] <message>
box dm read [<target=main>] [<limit=10>]
box thread list [<agent>]
box thread view <thread_id> [<limit=15>]
box health
box ping
box ops
Configuration:
EXEC_URL Endpoint (default: https://exec.muse-dev.online/exec)
EXEC_TOKEN Bearer token (or store in ~/.exec-token)
BOX_AGENT Agent identity (646, pip, muse, opm)
EOF
;;
*)
echo "Unknown command: $cmd (run 'box help')"
exit 1
;;
esac
+21 -1
View File
@@ -572,11 +572,31 @@ def dm_send(agent, target, message, verify=True, raw=False,
if nav_target != target: if nav_target != target:
log_event({"type": "alias_resolved", "id": msg_id, "target": target, "thread_uuid": nav_target, log_event({"type": "alias_resolved", "id": msg_id, "target": target, "thread_uuid": nav_target,
"source": resolve_sidechat_source(target)}) "source": resolve_sidechat_source(target)})
# Fast path: try fast headless gateway via muse_hybrid (isolated per-node WARP egress)
is_uuid = bool(re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", nav_target.strip().lower()))
if is_uuid and recipient in VALID_AGENTS:
try:
import muse_hybrid
gw_res, gw_err = muse_hybrid.send_message(recipient, tagged, thread_id=nav_target, wait=0)
if gw_res and not gw_err:
thread_uuid = nav_target
tags["thread"] = thread_uuid
log_event({"type": "verified", "id": msg_id, "agent": agent, "to": recipient,
"target": target, "thread_uuid": thread_uuid, "transport": "gateway",
"placement": "confirmed"})
log_event({"type": "send_done", "id": msg_id, "agent": agent, "to": recipient, "target": target})
log_event({"type": "sent", "id": msg_id, "agent": agent, "to": recipient, "target": target,
"verified": True, "transport": "gateway", "tags": tags})
print(f"DM {msg_id} from {agent} to {recipient}/{target}: SENT and VERIFIED thread={thread_uuid} (gateway)")
return msg_id
except Exception as _e:
log_event({"type": "gateway_fallback", "id": msg_id, "error": str(_e)[:100]})
if target == "main": if target == "main":
_rc, _out, _err = run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main") _rc, _out, _err = run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main")
else: else:
# Reset-at-Begin: If searching sidebar by title (not a direct UUID), reset to main first to expose the sidebar # Reset-at-Begin: If searching sidebar by title (not a direct UUID), reset to main first to expose the sidebar
is_uuid = bool(re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", nav_target.strip().lower()))
nav_is_uuid = is_uuid nav_is_uuid = is_uuid
if not is_uuid: if not is_uuid:
run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main") run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main")
+120 -8
View File
@@ -425,6 +425,18 @@ def _chat_send_build(a):
return argv return argv
def _safe_str(v, max_len=120, name='string'):
if v is None:
return ''
if not isinstance(v, str):
raise OpError(f'{name} must be a string')
if len(v) > max_len:
raise OpError(f'{name} exceeds max length {max_len}')
if any(ord(c) < 32 and c not in '\n\t' for c in v):
raise OpError(f'{name} contains control characters')
return v.strip()
def _health_validate(raw): def _health_validate(raw):
if raw not in ({}, None): if raw not in ({}, None):
raise OpError('health.check takes no args') raise OpError('health.check takes no args')
@@ -432,7 +444,83 @@ def _health_validate(raw):
def _health_build(a): def _health_build(a):
return ['/bin/bash', os.path.join(BIN_DIR, 'agent-health.sh'), '--check'] return [sys.executable, os.path.join(BIN_DIR, 'super-cli.py'), 'fleet', 'status', '--json']
def _subagent_spawn_validate(raw):
if not isinstance(raw, dict):
raise OpError('args must be an object')
allowed = {'agent', 'title', 'prompt', 'wait'}
for k in raw:
if k not in allowed:
raise OpError(f'unknown arg: {k}')
return {
'agent': _agent(raw.get('agent')),
'title': _safe_str(raw.get('title', 'subagent'), 120, 'title') or 'subagent',
'prompt': _clean_message(raw.get('prompt')),
'wait': _opt_int(raw.get('wait', 0), 0, 60, 'wait') or 0,
}
def _subagent_spawn_build(a):
return [sys.executable, os.path.join(BIN_DIR, 'super-cli.py'),
'deploy', 'subagent',
'--agent', a['agent'],
'--title', a['title'],
'--wait', str(a['wait']),
a['prompt']]
def _thread_list_validate(raw):
if not isinstance(raw, dict):
raise OpError('args must be an object')
allowed = {'agent'}
for k in raw:
if k not in allowed:
raise OpError(f'unknown arg: {k}')
return {'agent': _agent(raw.get('agent'))}
def _thread_list_build(a):
return [sys.executable, os.path.join(BIN_DIR, 'super-cli.py'),
'thread', 'list', a['agent'], '--json']
def _thread_view_validate(raw):
if not isinstance(raw, dict):
raise OpError('args must be an object')
allowed = {'agent', 'thread', 'limit'}
for k in raw:
if k not in allowed:
raise OpError(f'unknown arg: {k}')
th = raw.get('thread')
if not isinstance(th, str) or not TARGET_RE.fullmatch(th):
raise OpError('thread must match target pattern')
return {
'agent': _agent(raw.get('agent')),
'thread': th,
'limit': _opt_int(raw.get('limit', 15), 1, 100, 'limit') or 15,
}
def _thread_view_build(a):
return [sys.executable, os.path.join(BIN_DIR, 'super-cli.py'),
'thread', 'view', a['agent'], a['thread'],
'--limit', str(a['limit']), '--json']
def _pipeline_run_validate(raw):
if not isinstance(raw, dict):
raise OpError('args must be an object')
allowed = {'name'}
for k in raw:
if k not in allowed:
raise OpError(f'unknown arg: {k}')
return {'name': _job_name(raw.get('name'))}
def _pipeline_run_build(a):
return [sys.executable, os.path.join(BIN_DIR, 'super-cli.py'), 'deploy', 'pipeline', a['name']]
# op -> {validate, build, timeout, side_effecting, description} # op -> {validate, build, timeout, side_effecting, description}
@@ -469,8 +557,28 @@ OPS = {
}, },
'health.check': { 'health.check': {
'validate': _health_validate, 'build': _health_build, 'validate': _health_validate, 'build': _health_build,
'timeout': 120, 'side_effecting': False, 'timeout': 30, 'side_effecting': False,
'desc': 'Run agent-health.sh --check (read-only)', 'desc': 'Run fleet status health check (read-only)',
},
'subagent.spawn': {
'validate': _subagent_spawn_validate, 'build': _subagent_spawn_build,
'timeout': 120, 'side_effecting': True,
'desc': 'Spawn an autonomous subagent session via muse-cli gateway',
},
'thread.list': {
'validate': _thread_list_validate, 'build': _thread_list_build,
'timeout': 60, 'side_effecting': False,
'desc': 'List threads/sessions for an agent',
},
'thread.view': {
'validate': _thread_view_validate, 'build': _thread_view_build,
'timeout': 60, 'side_effecting': False,
'desc': 'View messages in a thread/session',
},
'pipeline.run': {
'validate': _pipeline_run_validate, 'build': _pipeline_run_build,
'timeout': 120, 'side_effecting': True,
'desc': 'Dispatch a multi-step pipeline across agents',
}, },
'exec.ping': { 'exec.ping': {
'validate': _health_validate, 'validate': _health_validate,
@@ -486,13 +594,17 @@ OPS = {
PERMISSIONS = { PERMISSIONS = {
'master': set(OPS), 'master': set(OPS),
'operator-main': set(OPS), 'operator-main': set(OPS),
'operator-646': {'dm.send', 'dm.thread', 'dm.read', 'job.run', 'operator-646': set(OPS),
'chat.messages', 'health.check', 'exec.ping'}, 'operator-muse': set(OPS),
'operator-muse': {'dm.send', 'dm.read', 'chat.messages', 'exec.ping'}, 'operator-pip': set(OPS),
'operator-pip': {'dm.send', 'dm.read', 'chat.messages', 'exec.ping'}, 'operator-opm': set(OPS),
'646': set(OPS),
'pip': set(OPS),
'muse': set(OPS),
'opm': set(OPS),
'exec-canary': {'exec.ping'}, 'exec-canary': {'exec.ping'},
} }
DEFAULT_PERMS = {'dm.read', 'chat.messages', 'health.check', 'exec.ping'} DEFAULT_PERMS = {'dm.read', 'chat.messages', 'health.check', 'thread.list', 'thread.view', 'exec.ping'}
def permitted(ident, op): def permitted(ident, op):
+39
View File
@@ -261,6 +261,45 @@ def send_dm(agent, target, message, dry_run=False, followup_tags=None,
except Exception as se: except Exception as se:
sys.stderr.write(f"warning: dm signing failed: {se}\n") sys.stderr.write(f"warning: dm signing failed: {se}\n")
payload_to_send = signed_payload or message
# Try fast hybrid gateway send if target resolves to UUID
target_uuid = None
try:
import dm
target_uuid = dm.resolve_sidechat_target(target, agent)
except Exception:
pass
if target_uuid and re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", target_uuid.lower()):
try:
import muse_hybrid
send_node = agent if agent in ("muse", "pip", "646", "opm") else "opm"
res, err = muse_hybrid.send_message(send_node, payload_to_send, thread_id=target_uuid, wait=0)
if res and not err:
m_id = None
id_m = re.search(r"\[id:([a-f0-9]+)\]", payload_to_send)
if id_m:
m_id = id_m.group(1)
log_entry = {
"type": "sent",
"id": m_id or (res.get("reply", {}).get("message_id") if isinstance(res, dict) else "gateway"),
"agent": "opm",
"to": agent,
"target": target,
"thread_uuid": target_uuid,
"transport": "gateway",
"verified": True,
"ts": datetime.now(timezone.utc).isoformat()
}
dm_log_path = NETVM_ROOT / "dm-log.jsonl"
with open(dm_log_path, "a", encoding="utf-8") as lf:
lf.write(json.dumps(log_entry) + "\n")
return m_id or "gateway-verified"
sys.stderr.write(f"warning: fast gateway send fallback: {err or res}\n")
except Exception as ge:
sys.stderr.write(f"warning: fast gateway send fallback: {ge}\n")
if signed_payload: if signed_payload:
cmd = ([str(DM_PY), "send", "--agent", "opm", "--to", agent, cmd = ([str(DM_PY), "send", "--agent", "opm", "--to", agent,
"--target", target, "--raw"] "--target", target, "--raw"]
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""job-scheduler.py — NetVM unified job scheduler.
Evaluates cron schedules in jobs/*.json and triggers due jobs via job-dispatch.py.
Prevents duplicate dispatches using state watermarks in /home/super/Projects/NetVM/job-scheduler-state.json.
Usage:
python3 bin/job-scheduler.py run [--dry-run]
python3 bin/job-scheduler.py status
"""
import os
import sys
import glob
import json
import fcntl
import argparse
import subprocess
from datetime import datetime, timezone, timedelta
BASE = "/home/super/Projects/NetVM"
JOBS_DIR = os.path.join(BASE, "jobs")
BIN = os.path.join(BASE, "bin")
JOB_DISPATCH = os.path.join(BIN, "job-dispatch.py")
STATE_FILE = os.path.join(BASE, "job-scheduler-state.json")
LOCK_FILE = os.path.join(BASE, "job-scheduler.lock")
def utcnow():
return datetime.now(timezone.utc)
def parse_field(pattern, val):
if pattern == "*":
return True
for part in pattern.split(","):
if "/" in part:
sub = part.split("/")
step = int(sub[1])
base = sub[0]
start = 0 if base == "*" else int(base.split("-")[0])
end = 59 if base == "*" else int(base.split("-")[-1])
if start <= val <= end and (val - start) % step == 0:
return True
elif "-" in part:
s, e = map(int, part.split("-"))
if s <= val <= e:
return True
elif part.isdigit() and int(part) == val:
return True
return False
def cron_matches(expr, dt):
"""Check if 5-field cron expression matches datetime dt."""
parts = expr.strip().split()
if len(parts) != 5:
return False
m, h, dom, mon, dow = parts
dow_val = (dt.weekday() + 1) % 7 # 0=Sunday
return (parse_field(m, dt.minute) and
parse_field(h, dt.hour) and
parse_field(dom, dt.day) and
parse_field(mon, dt.month) and
(parse_field(dow, dt.weekday() + 1) or parse_field(dow, dow_val)))
def is_job_due(expr, now_dt, last_fired_dt=None):
"""Determine if a cron job is due within the recent 5-minute sampling window."""
if not expr or expr.strip().lower() == "manual":
return False
# Check minutes in the window [now - 4min, now]
matched_dt = None
for offset in range(5):
sample_dt = now_dt - timedelta(minutes=offset)
if cron_matches(expr, sample_dt):
matched_dt = sample_dt.replace(second=0, microsecond=0)
break
if not matched_dt:
return False
if last_fired_dt:
# If fired within 4 minutes of the matched slot, skip duplicate
diff_seconds = (now_dt - last_fired_dt).total_seconds()
# For hourly or longer jobs, prevent re-fire within 45 minutes
if " " in expr and expr.split()[0] != "*":
if diff_seconds < 2700:
return False
elif diff_seconds < 240:
return False
return True
def load_state():
try:
with open(STATE_FILE, "r") as f:
return json.load(f)
except Exception:
return {"jobs": {}, "last_run": None}
def save_state(state):
tmp = STATE_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(state, f, indent=2)
os.replace(tmp, STATE_FILE)
def do_run(dry_run=False):
now = utcnow()
now_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ")
state = load_state()
jobs_state = state.setdefault("jobs", {})
job_files = sorted(glob.glob(os.path.join(JOBS_DIR, "*.json")))
dispatched = []
skipped = []
for jpath in job_files:
try:
with open(jpath, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
continue
job_name = data.get("name") or os.path.basename(jpath).replace(".json", "")
schedule = data.get("schedule")
if not schedule or schedule.strip().lower() == "manual":
continue
j_st = jobs_state.get(job_name, {})
last_fired_str = j_st.get("last_fired")
last_fired_dt = None
if last_fired_str:
try:
last_fired_dt = datetime.fromisoformat(last_fired_str.replace("Z", "+00:00"))
except Exception:
pass
if is_job_due(schedule, now, last_fired_dt):
if dry_run:
print(f"[DRY RUN] Due job: {job_name} ({schedule})")
dispatched.append(job_name)
continue
cmd = [sys.executable, JOB_DISPATCH, job_name]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if r.returncode == 0:
dispatched.append(job_name)
jobs_state[job_name] = {
"last_fired": now_iso,
"schedule": schedule,
"status": "dispatched"
}
print(f"Dispatched job: {job_name} ({schedule})", file=sys.stderr)
else:
err = (r.stderr or r.stdout).strip()[-200:]
print(f"Failed to dispatch {job_name}: {err}", file=sys.stderr)
except Exception as e:
print(f"Exception dispatching {job_name}: {e}", file=sys.stderr)
else:
skipped.append(job_name)
if not dry_run:
state["last_run"] = now_iso
save_state(state)
result = {
"ok": True,
"dispatched": dispatched,
"dispatched_count": len(dispatched),
"evaluated_at": now_iso
}
return result
def main():
p = argparse.ArgumentParser(description="NetVM Unified Job Scheduler")
sub = p.add_subparsers(dest="cmd")
p_run = sub.add_parser("run", help="Evaluate schedules and dispatch due jobs")
p_run.add_argument("--dry-run", action="store_true", help="Print due jobs without dispatching")
sub.add_parser("status", help="Show scheduler state and last run")
args = p.parse_args()
cmd = args.cmd or "run"
if cmd == "status":
print(json.dumps(load_state(), indent=2))
return 0
if cmd == "run":
try:
lockfh = open(LOCK_FILE, "w")
fcntl.flock(lockfh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, IOError):
print(json.dumps({"ok": False, "skipped": "already running"}))
return 0
res = do_run(dry_run=getattr(args, "dry_run", False))
print(json.dumps(res))
return 0
return 0
if __name__ == "__main__":
sys.exit(main())
+16 -3
View File
@@ -24,7 +24,7 @@ def is_node_configured(node):
conf_dir = Path.home() / ".config" / "muse-cli" / node conf_dir = Path.home() / ".config" / "muse-cli" / node
return (conf_dir / "cookies.txt").exists() return (conf_dir / "cookies.txt").exists()
def run_muse_cli(node, args, timeout=30): def run_muse_cli(node, args, timeout=60):
cmd = [str(MUSE_CLI_NODE), node] + args cmd = [str(MUSE_CLI_NODE), node] + args
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return res.returncode, res.stdout, res.stderr return res.returncode, res.stdout, res.stderr
@@ -40,6 +40,20 @@ def get_threads(node):
except Exception as e: except Exception as e:
return None, f"JSON parse error: {e}" return None, f"JSON parse error: {e}"
def start_session(node, title=None):
"""Start a new session/subagent via fast gateway. Returns (dict, error_str)."""
args = ["session-start"]
if title:
args.extend(["--title", title])
rc, stdout, stderr = run_muse_cli(node, args)
if rc != 0:
return None, stderr or stdout
try:
data = json.loads(stdout)
return data, None
except Exception as e:
return None, f"JSON parse error: {e}"
def get_history(node, thread_id=None, limit=15): def get_history(node, thread_id=None, limit=15):
"""Retrieve message history via fast gateway. Returns (history_list, error_str).""" """Retrieve message history via fast gateway. Returns (history_list, error_str)."""
args = ["history", "--limit", str(limit)] args = ["history", "--limit", str(limit)]
@@ -59,10 +73,9 @@ def send_message(node, text, thread_id=None, wait=0):
args = ["send"] args = ["send"]
if thread_id and thread_id != "main": if thread_id and thread_id != "main":
args.extend(["--thread", str(thread_id)]) args.extend(["--thread", str(thread_id)])
if wait > 0:
args.extend(["--wait", str(wait)]) args.extend(["--wait", str(wait)])
args.append(text) args.append(text)
rc, stdout, stderr = run_muse_cli(node, args, timeout=max(30, wait + 15)) rc, stdout, stderr = run_muse_cli(node, args, timeout=max(60, wait + 30))
if rc != 0: if rc != 0:
return None, stderr or stdout return None, stderr or stdout
try: try:
+34 -1
View File
@@ -55,6 +55,39 @@ def create_pipeline(pipeline_name, run_id=None, custom_target=None):
data = load_pipelines() data = load_pipelines()
target = custom_target or f"pipe-{run_id.split('-')[-1]}" target = custom_target or f"pipe-{run_id.split('-')[-1]}"
# Fast headless gateway provisioning: create session server-side
thread_uuid = None
job_file = JOBS_DIR / f"{pipeline_name}.json"
root_agent = "opm"
if job_file.exists():
try:
with open(job_file) as f:
root_agent = json.load(f).get("agent", "opm")
except Exception:
pass
try:
import muse_hybrid
res, err = muse_hybrid.start_session(root_agent, title=target)
if res and not err:
thread_uuid = res.get("session_id")
# Register in job-sidechats.json so dm.py resolves it immediately
sc_file = NETVM_ROOT / "job-sidechats.json"
if sc_file.exists():
with open(sc_file, "r", encoding="utf-8") as f:
sc_data = json.load(f)
sc_data[target] = {
"thread_uuid": thread_uuid,
"agent": root_agent,
"created_at": utcnow()
}
tmp_sc = f"{sc_file}.tmp.{os.getpid()}"
with open(tmp_sc, "w", encoding="utf-8") as f:
json.dump(sc_data, f, indent=2)
os.replace(tmp_sc, sc_file)
except Exception as e:
sys.stderr.write(f"warning: fast gateway session creation failed: {e}\n")
entry = { entry = {
"run_id": run_id, "run_id": run_id,
"pipeline_name": pipeline_name, "pipeline_name": pipeline_name,
@@ -62,7 +95,7 @@ def create_pipeline(pipeline_name, run_id=None, custom_target=None):
"updated_at": utcnow(), "updated_at": utcnow(),
"status": "running", "status": "running",
"target": target, "target": target,
"thread_uuid": None, "thread_uuid": thread_uuid,
"current_step": 1, "current_step": 1,
"steps": [], "steps": [],
} }
+74 -1
View File
@@ -1246,8 +1246,8 @@ def cmd_thread_view(args):
# Fallback to box-chat.py / CDP if gateway returned no messages # Fallback to box-chat.py / CDP if gateway returned no messages
if not messages: if not messages:
cmd = ["python3", str(BIN_DIR / "box-chat.py"), "thread-messages", agent, thread_id, "--limit", str(limit)] cmd = ["python3", str(BIN_DIR / "box-chat.py"), "thread-messages", agent, thread_id, "--limit", str(limit)]
res = subprocess.run(cmd, capture_output=True, text=True)
try: try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
data = json.loads(res.stdout) data = json.loads(res.stdout)
messages = data.get("messages", []) if data.get("ok") else [] messages = data.get("messages", []) if data.get("ok") else []
except Exception: except Exception:
@@ -2502,6 +2502,63 @@ def cmd_pipeline_prune(args):
print(json.dumps({"pruned": pruned, "count": len(pruned)}, indent=2)) print(json.dumps({"pruned": pruned, "count": len(pruned)}, indent=2))
# ---------------------------------------------------------------------------
# Domain: DEPLOY (Multi-Agent Pipelines, Subagent Spawning, and Oversight)
# ---------------------------------------------------------------------------
def cmd_deploy(args):
action = getattr(args, "action", None)
if action == "subagent":
agent = args.agent
title = args.title or "subagent-task"
prompt = args.prompt
wait_val = getattr(args, "wait", 30)
wait = 30 if wait_val is None else int(wait_val)
print(c_bold(f"\n=== DEPLOYING SUBAGENT ON [{agent}] ==="))
print(f" Agent: {c_cyan(agent)}")
print(f" Title: {c_yellow(title)}")
print(f" Prompt: {c_dim(prompt[:90])}...\n")
import muse_hybrid
res, err = muse_hybrid.start_session(agent, title=title)
if err or not res:
print(c_err(f"✖ Failed to spawn subagent session: {err}"), file=sys.stderr)
sys.exit(1)
session_id = res.get("session_id")
print(c_green(f"✔ Subagent session spawned: {session_id}"))
if prompt:
print(f" Dispatching task prompt to subagent (waiting up to {wait}s)...")
send_res, send_err = muse_hybrid.send_message(agent, prompt, thread_id=session_id, wait=wait)
if send_err:
print(c_warn(f"Notice: {send_err}"))
else:
print(c_green("✔ Task prompt delivered."))
# Fetch fresh history to show assistant's initial work
msgs, _ = muse_hybrid.get_history(agent, thread_id=session_id, limit=5)
if msgs:
for m in msgs:
role = m.get("role")
if role == "assistant":
print(f"\n{c_bold('--- Subagent Response ---')}\n{m.get('text')}\n")
print(f" Oversee anytime with: {c_cyan(f'box thread view {agent} {session_id}')}\n")
return
# Pipeline deployment
pipe_name = getattr(args, "name", None)
if not pipe_name:
print(c_err("Error: Specify pipeline name (e.g. box deploy pipeline pipe-demo-step1)"), file=sys.stderr)
sys.exit(1)
args.name = pipe_name
cmd_pipeline_run(args)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Domain: LOOP (Intrinsic Loop Strategy, Health, Break Taxonomy, and Control) # Domain: LOOP (Intrinsic Loop Strategy, Health, Break Taxonomy, and Control)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -3333,6 +3390,20 @@ def build_parser():
p_va_rb.add_argument("name", help="Variable name") p_va_rb.add_argument("name", help="Variable name")
p_va_rb.add_argument("--revision", default=None, help="Revision step (int) or timestamp") p_va_rb.add_argument("--revision", default=None, help="Revision step (int) or timestamp")
# Domain: deploy (Deploy multi-agent pipelines or subagent tasks with oversight)
p_deploy = subparsers.add_parser("deploy", parents=[common], help="Deploy multi-agent task or pipeline across nodes with live oversight")
deploy_sub = p_deploy.add_subparsers(dest="action")
p_dep_pipe = deploy_sub.add_parser("pipeline", parents=[common], help="Deploy multi-agent pipeline")
p_dep_pipe.add_argument("name", help="Pipeline root job name (e.g. pipe-demo-step1)")
p_dep_pipe.add_argument("--dry-run", action="store_true", help="Simulate pipeline without sending DMs")
p_dep_sub = deploy_sub.add_parser("subagent", parents=[common], help="Spawn sub-agent session and dispatch task")
p_dep_sub.add_argument("--agent", required=True, choices=VALID_NODES, help="Agent node to spawn subagent on")
p_dep_sub.add_argument("--title", default="subagent-task", help="Title for the subagent session")
p_dep_sub.add_argument("prompt", help="Task prompt for the subagent")
p_dep_sub.add_argument("--wait", type=int, default=30, help="Seconds to wait for subagent response")
# Domain: muse (fast headless gateway via muse-cli-node with isolated per-node Cloudflare WARP egress) # Domain: muse (fast headless gateway via muse-cli-node with isolated per-node Cloudflare WARP egress)
p_muse = subparsers.add_parser("muse", parents=[common], help="Direct headless gateway client (muse-cli-node)") p_muse = subparsers.add_parser("muse", parents=[common], help="Direct headless gateway client (muse-cli-node)")
p_muse.add_argument("node", choices=VALID_NODES, help="Target agent node") p_muse.add_argument("node", choices=VALID_NODES, help="Target agent node")
@@ -3506,6 +3577,8 @@ def main():
cmd_loop_strat(args) cmd_loop_strat(args)
elif args.domain == "vars": elif args.domain == "vars":
cmd_loop_vars(args) cmd_loop_vars(args)
elif args.domain == "deploy":
cmd_deploy(args)
elif args.domain == "muse": elif args.domain == "muse":
cmd = [str(BIN_DIR / "muse-cli-node"), args.node] + (args.muse_args or []) cmd = [str(BIN_DIR / "muse-cli-node"), args.node] + (args.muse_args or [])
res = subprocess.run(cmd) res = subprocess.run(cmd)
+74
View File
@@ -0,0 +1,74 @@
# Agent Tooling & Subagent Delegation Guide
Welcome, operator. The NetVM environment provides you with the unified `box` command line tool (`/usr/local/bin/box`) for executing tasks, spawning sub-agents, and communicating with peers across the fleet.
---
## 1. Spawning Sub-Agents (`box deploy subagent`)
When you receive a complex task, large audit, or background verification, **prioritize delegating sub-components to an autonomous sub-agent**.
```bash
box deploy subagent --agent <self> --title "<task-name>" "<prompt>"
```
### Parameters:
- `--agent`: Your own identity (`646`, `pip`, `opm`, or `muse`).
- `--title`: Brief descriptive title for the sub-agent session.
- `prompt`: The specific instructions and criteria for the sub-agent.
### Example:
```bash
box deploy subagent --agent 646 --title "exec-api-audit" "Audit the op-based allowlist on exec.muse-dev.online and report back valid ops."
```
---
## 2. Cross-Operator DMs & Relay (`box dm send`)
To coordinate with your peer operators (`opm`, `646`, `pip`, `muse`) or hand off tasks across sidechats:
```bash
box dm send --agent <self> --to <recipient> --target <sidechat> "<message>"
```
### Target Sidechats:
- `646-pip`: Bilateral coordination thread between 646 and Pip.
- `646-opm-coord`: Coordination thread between 646 and OPM.
- `<agent> tasks`: Dedicated task sidechat for an individual agent (e.g. `646 tasks`, `pip tasks`).
### Example:
```bash
box dm send --agent 646 --to pip --target 646-pip "Hey Pip, start-page onboarding review draft is ready. Please confirm when ready to review."
```
---
## 3. Direct Gateway Tooling (`box muse`)
You can directly interact with the headless Muse gateway inside your isolated network namespace:
```bash
# List all your active threads
box muse <self> threads
# Read message history in a thread
box muse <self> history --thread <thread_uuid> --limit 10
# Check unread messages
box muse <self> unread
# Start a new thread / session
box muse <self> session-start --title "<title>"
# Fast fire-and-forget message send
box muse <self> send --thread <thread_uuid> "<message>"
```
---
## 4. Best Practices & Invariants
1. **Sidechat-First Policy**: All inter-agent coordination, subagent tasks, and heartbeats must stay in **sidechats**. Do not send automated routine messages to `main` chat.
2. **Sub-Agent Prioritization**: Break down complex diagnostic or verification jobs by delegating sub-tasks to dedicated subagent threads.
3. **Attribution & Result Tagging**: For scheduled jobs, always conclude your response with `[RESULT <job_id>] <summary>`.
+32
View File
@@ -62,12 +62,24 @@
"agent": "646", "agent": "646",
"created_at": "2026-10-04T18:30:21.863666+00:00" "created_at": "2026-10-04T18:30:21.863666+00:00"
}, },
"646-tasks": {
"thread_uuid": "1dfb3199-2f99-446c-83e3-848ae2da0a12",
"agent": "646"
},
"pip tasks": { "pip tasks": {
"thread_uuid": "5f33b1ab-a310-4cbb-9ccc-bb7bcd3d1b34", "thread_uuid": "5f33b1ab-a310-4cbb-9ccc-bb7bcd3d1b34",
"agent": "pip", "agent": "pip",
"description": "Dedicated pip task prompting sidechat", "description": "Dedicated pip task prompting sidechat",
"created_at": "2026-10-04T22:59:13+00:00" "created_at": "2026-10-04T22:59:13+00:00"
}, },
"pip-tasks": {
"thread_uuid": "5f33b1ab-a310-4cbb-9ccc-bb7bcd3d1b34",
"agent": "pip"
},
"muse-tasks": {
"thread_uuid": "414cc0fc-4c46-4cb4-9fa6-07f06912384a",
"agent": "muse"
},
"pipe-c3f686": { "pipe-c3f686": {
"thread_uuid": "afa977ba-c75b-4b3e-8c1f-f83f13fb287e", "thread_uuid": "afa977ba-c75b-4b3e-8c1f-f83f13fb287e",
"agent": "opm", "agent": "opm",
@@ -98,5 +110,25 @@
"thread_uuid": "4466d0c1-7961-4cf3-b99d-1ab7c38484c2", "thread_uuid": "4466d0c1-7961-4cf3-b99d-1ab7c38484c2",
"agent": "pip", "agent": "pip",
"description": "pip side of 646-pip coordination pair" "description": "pip side of 646-pip coordination pair"
},
"pipe-a0d377": {
"thread_uuid": "cc0aee8f-531a-40af-9e02-27459f57b3ae",
"agent": "opm",
"created_at": "2026-10-04T23:15:34.981356+00:00"
},
"pipe-3a153d": {
"thread_uuid": "29066a5c-878f-4d9d-b0f8-32405088c2d8",
"agent": "opm",
"created_at": "2026-10-04T23:16:16.726507+00:00"
},
"pipe-921583": {
"thread_uuid": "70c1e8a6-e3f7-4ef2-9c32-8207b4f8c44f",
"agent": "opm",
"created_at": "2026-10-04T23:17:25.127456+00:00"
},
"pipe-b3f7c4": {
"thread_uuid": "8f9ae8e7-dd3a-46e6-a5ac-3b1bb7cf6d66",
"agent": "opm",
"created_at": "2026-10-04T23:19:20.218056+00:00"
} }
} }
+17
View File
@@ -0,0 +1,17 @@
{
"name": "646-hourly-checkin",
"description": "Hourly operational check-in for 646 during active daytime hours",
"agent": "646",
"schedule": "0 8-22 * * *",
"timeout": 300,
"on_failure": "alert",
"dm_target": "646 tasks",
"sidechat": {"create": false},
"followup": {
"expect_reply": true,
"timeout": "1h",
"nudges": 1,
"escalate": "opm"
},
"prompt_template": "Hourly operational check-in for 646.\nJob ID: {job_id}\nTime: {datetime}\n\nPlease report briefly in this thread:\n(1) Current tasks in flight\n(2) VM / service health status\n(3) Any blockers or peer coordination items\n\nReply with [RESULT {job_id}] and your status."
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "muse-hourly-checkin",
"description": "Hourly operational check-in for Muse during active daytime hours",
"agent": "muse",
"schedule": "0 8-22 * * *",
"timeout": 300,
"on_failure": "alert",
"dm_target": "muse tasks",
"sidechat": {"create": false},
"followup": {
"expect_reply": true,
"timeout": "1h",
"nudges": 1,
"escalate": "opm"
},
"prompt_template": "Hourly operational check-in for Muse.\nJob ID: {job_id}\nTime: {datetime}\n\nPlease report briefly in this thread:\n(1) Current tasks and codebase status\n(2) Verdicts / review items\n(3) Any blockers or peer coordination items\n\nReply with [RESULT {job_id}] and your status."
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "pip-hourly-checkin",
"description": "Hourly operational check-in for Pip during active daytime hours",
"agent": "pip",
"schedule": "0 8-22 * * *",
"timeout": 300,
"on_failure": "alert",
"dm_target": "pip tasks",
"sidechat": {"create": false},
"followup": {
"expect_reply": true,
"timeout": "1h",
"nudges": 1,
"escalate": "opm"
},
"prompt_template": "Hourly operational check-in for Pip.\nJob ID: {job_id}\nTime: {datetime}\n\nPlease report briefly in this thread:\n(1) Current tasks and workspace status\n(2) Blockers (start-page draft, authorization, or netns items)\n(3) Readiness / next steps\n\nReply with [RESULT {job_id}] and your status."
}
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=NetVM Unified Job Scheduler
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /home/super/Projects/NetVM/bin/job-scheduler.py run
WorkingDirectory=/home/super/Projects/NetVM
StandardOutput=journal
StandardError=journal
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Run NetVM Unified Job Scheduler every 5 minutes
[Timer]
OnCalendar=*:0/5
Persistent=true
[Install]
WantedBy=timers.target