diff --git a/bin/box-ctl.py b/bin/box-ctl.py index 8d43547..9af91a0 100755 --- a/bin/box-ctl.py +++ b/bin/box-ctl.py @@ -197,9 +197,10 @@ def cron_to_oncalendar(schedule): # M H DOM * * -> *-*-DOM HH:MM:00 (monthly) if m.isdigit() and h.isdigit() and dom.isdigit() and is_star(mon) and is_star(dow): return f"*-*-{int(dom):02d} {int(h):02d}:{int(m):02d}:00" - # M H * * * with stepped hour: M */n * * * -> 0/n:MM - if m.isdigit() and h.startswith("*/") and h[2:].isdigit() and all(is_star(f) for f in (dom, mon, dow)): - return f"0/{h[2:]}:{int(m):02d}" + # M,M... * * * * -> *:M1,M2... + if all(part.isdigit() for part in m.split(",")) and all(is_star(f) for f in (h, dom, mon, dow)): + formatted_m = ",".join(f"{int(part):02d}" for part in m.split(",")) + return f"*:{formatted_m}" return None @@ -885,9 +886,12 @@ def _valid_sidechat_name(name): ord(c) < 32 or ord(c) == 127 for c in name) -def act_notify(agent, message, sidechat=None, allow_main_chat=False): +def act_notify(agent, message, sidechat=None, allow_main_chat=False, sender=None): if agent not in VALID_AGENTS: fail("BAD_NAME", f"agent must be one of {sorted(VALID_AGENTS)}") + if sender is not None and sender not in VALID_AGENTS: + fail("BAD_NAME", f"sender must be one of {sorted(VALID_AGENTS)}") + sender_agent = sender if sender else "opm" if not message or len(message) > 1000: fail("INVALID_JOB", "message must be 1\u20131000 chars") # Sidechat-first policy: default to the agent's sidechat, never main. @@ -902,7 +906,7 @@ def act_notify(agent, message, sidechat=None, allow_main_chat=False): fail("BAD_NAME", "sidechat name must be 1-64 chars, no control characters") extra = [] r = run([sys.executable, str(DM_PY), "send", - "--agent", "opm", "--to", agent, "--target", target, + "--agent", sender_agent, "--to", agent, "--target", target, *extra, message], timeout=120) if r.returncode != 0: @@ -2642,6 +2646,30 @@ def main(argv): if len(rest) != 1: fail("BAD_NAME", f"usage: {action} ") act_timer_control(rest[0], action[len("timer-"):]) + elif action == "timer": + if not rest: + act_timer_list() + else: + sub = rest[0] + if sub in ("list",): + act_timer_list() + elif sub in ("status",): + if len(rest) != 2: + fail("BAD_NAME", "usage: timer status ") + act_timer_status(rest[1]) + elif sub in ("create",): + if len(rest) != 2: + fail("BAD_NAME", "usage: timer create ") + act_timer_create(rest[1]) + elif sub in ("delete",): + keep = "--keep-job" in rest[2:] + act_timer_delete(rest[1], keep_job=keep) + elif sub in ("start", "stop", "enable", "disable"): + if len(rest) != 2: + fail("BAD_NAME", f"usage: timer {sub} ") + act_timer_control(rest[1], sub) + else: + fail("BAD_NAME", "usage: timer list|status|create|delete|start|stop|enable|disable [...]") elif action == "job-list": act_job_list() elif action == "job-get": @@ -2809,22 +2837,29 @@ def main(argv): fail("BAD_NAME", f"usage: {action} --agent --thread [--title ]") act_thread(op, agent, thread, title) elif action == "notify": - # notify <agent> <message> [--sidechat <name>] [--allow-main-chat] + # notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>] args = list(rest) allow_main = False sidechat = None + sender = None if "--allow-main-chat" in args: allow_main = True args.remove("--allow-main-chat") if "--sidechat" in args: i = args.index("--sidechat") if i + 1 >= len(args): - fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat]") + fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]") sidechat = args[i + 1] del args[i:i + 2] + if "--sender" in args: + i = args.index("--sender") + if i + 1 >= len(args): + fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]") + sender = args[i + 1] + del args[i:i + 2] if len(args) != 2: - fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat]") - act_notify(args[0], args[1], sidechat=sidechat, allow_main_chat=allow_main) + fail("BAD_NAME", "usage: notify <agent> <message> [--sidechat <name>] [--allow-main-chat] [--sender <agent>]") + act_notify(args[0], args[1], sidechat=sidechat, allow_main_chat=allow_main, sender=sender) elif action == "policy": if not rest: act_policy() diff --git a/bin/box-relay.sh b/bin/box-relay.sh index 4a4c133..eceb19a 100755 --- a/bin/box-relay.sh +++ b/bin/box-relay.sh @@ -296,8 +296,31 @@ case "$cmd" in args=$(python3 -c "import json, sys; print(json.dumps({'name': sys.argv[1]}))" "$name") call_exec "cron.status" "$args" ;; + in|delay) + min="${1:?usage: box timer in <minutes> <prompt>}" + shift + prompt="${*:?usage: box timer in <minutes> <prompt>}" + args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1], 'in_m': float(sys.argv[2]), 'prompt': sys.argv[3]}))" "$AGENT" "$min" "$prompt") + call_exec "followup.create" "$args" + ;; *) - echo "Usage: box timer create|start|status <name>" + echo "Usage: box timer create|start|status <name> OR box timer in <minutes> <prompt>" + ;; + esac + ;; + followup) + sub="${1:-create}" + shift || true + case "$sub" in + create|set|schedule) + min="${1:?usage: box followup create <minutes> <prompt>}" + shift + prompt="${*:?usage: box followup create <minutes> <prompt>}" + args=$(python3 -c "import json, sys; print(json.dumps({'agent': sys.argv[1], 'in_m': float(sys.argv[2]), 'prompt': sys.argv[3]}))" "$AGENT" "$min" "$prompt") + call_exec "followup.create" "$args" + ;; + *) + echo "Usage: box followup create <minutes> <prompt>" ;; esac ;; diff --git a/bin/box-sys-op.py b/bin/box-sys-op.py index 2c54e55..f56bc5c 100755 --- a/bin/box-sys-op.py +++ b/bin/box-sys-op.py @@ -130,6 +130,44 @@ def op_service_restart(unit): "error": r.stderr.strip() if r.returncode != 0 else None } +def op_followup_schedule(args): + agent = args.get("agent") + if agent not in ("muse", "pip", "646", "opm", "dev", "def"): + return {"ok": False, "error": f"Invalid agent: {agent}"} + sender = args.get("sender") or agent + if sender not in ("muse", "pip", "646", "opm", "dev", "def"): + sender = agent + try: + in_m = float(args.get("in_m", 1)) + except (TypeError, ValueError): + return {"ok": False, "error": "in_m must be a number"} + sec = max(5, int(in_m * 60)) + prompt = args.get("prompt", "") + if not prompt or not isinstance(prompt, str): + return {"ok": False, "error": "prompt must be a non-empty string"} + if len(prompt) > 1000: + return {"ok": False, "error": "prompt exceeds 1000 characters"} + + sidechat = args.get("thread") or args.get("sidechat") + cmd = [ + "systemd-run", "--user", f"--on-active={sec}s", + sys.executable, str(REPO_ROOT / "bin" / "box-ctl.py"), + "notify", agent, prompt, "--sender", sender + ] + if sidechat and isinstance(sidechat, str) and len(sidechat) <= 64: + cmd.extend(["--sidechat", sidechat]) + + r = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + if r.returncode != 0: + return {"ok": False, "error": r.stderr.strip() or r.stdout.strip()} + return { + "ok": True, + "agent": agent, + "in_seconds": sec, + "sidechat": sidechat, + "timer_info": (r.stderr or r.stdout).strip() + } + def main(): if len(sys.argv) < 2: print(json.dumps({"ok": False, "error": "missing operation"})) @@ -153,6 +191,8 @@ def main(): res = op_service_status(args.get("unit", args.get("name", ""))) elif op == "service.restart": res = op_service_restart(args.get("unit", args.get("name", ""))) + elif op in ("followup.create", "followup.schedule"): + res = op_followup_schedule(args) else: res = {"ok": False, "error": f"unknown operation: {op}"} except Exception as e: diff --git a/bin/exec-constrained.py b/bin/exec-constrained.py index 96aa50b..58d45e2 100755 --- a/bin/exec-constrained.py +++ b/bin/exec-constrained.py @@ -733,6 +733,40 @@ def _service_restart_build(a): return [sys.executable, os.path.join(BIN_DIR, 'box-sys-op.py'), 'service.restart'] +def _followup_create_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'agent', 'in_m', 'prompt', 'thread', 'sidechat', 'sender'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + agent = _agent(raw.get('agent', 'opm')) + sender = raw.get('sender') + if sender: + sender = _agent(sender) + try: + in_m = float(raw.get('in_m', 1)) + except (TypeError, ValueError): + raise OpError('in_m must be a number') + if in_m < 0.05 or in_m > 1440: + raise OpError('in_m must be between 0.05 and 1440 minutes') + prompt = _clean_message(raw.get('prompt')) + sidechat = raw.get('thread') or raw.get('sidechat') + if sidechat and not TARGET_RE.fullmatch(str(sidechat)): + raise OpError('thread/sidechat must match safe identifier') + return { + 'agent': agent, + 'sender': sender or agent, + 'in_m': in_m, + 'prompt': prompt, + 'thread': str(sidechat) if sidechat else None + } + + +def _followup_create_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-sys-op.py'), 'followup.create'] + + def _swarm_spawn_validate(raw): if not isinstance(raw, dict): raise OpError('args must be an object') @@ -924,6 +958,11 @@ OPS = { 'timeout': 30, 'side_effecting': False, 'desc': 'List all subagent swarms and their counts', }, + 'followup.create': { + 'validate': _followup_create_validate, 'build': _followup_create_build, + 'timeout': 30, 'side_effecting': True, + 'desc': 'Schedule a delayed autonomous self-followup reminder via transient systemd timer', + }, 'exec.ping': { 'validate': _health_validate, 'build': lambda a: ['/bin/echo', 'PONG'], @@ -1114,7 +1153,7 @@ class Handler(BaseHTTPRequestHandler): ).hexdigest()[:16]}) t0 = time.monotonic() try: - stdin_input = json.dumps(clean) if op.startswith(('files.', 'web.', 'service.')) else None + stdin_input = json.dumps(clean) if op.startswith(('files.', 'web.', 'service.', 'followup.')) else None p = subprocess.run(argv, input=stdin_input, capture_output=True, text=True, timeout=spec['timeout'], cwd=WORK_DIR) rc = p.returncode diff --git a/bin/response-harvester.py b/bin/response-harvester.py index 5e74102..72603b6 100755 --- a/bin/response-harvester.py +++ b/bin/response-harvester.py @@ -274,10 +274,23 @@ def format_tool_result_for_chat(op, raw_output): st = "ACTIVE" if data.get("active") else "INACTIVE" return f"Service `{data.get('service')}` is {st}.\nStatus: {data.get('status_line')}" - if op == "service.restart" and isinstance(data, dict): + if op in ("followup.create", "followup.schedule") and isinstance(data, dict): if not data.get("ok"): - return f"Service restart failed for `{data.get('service')}`: {data.get('error')}" - return f"Service `{data.get('service')}` restarted successfully." + return f"Follow-up scheduling failed: {data.get('error')}" + sec = data.get("in_seconds", 60) + ag = data.get("agent", "") + return f"Follow-up timer scheduled for {ag} in {sec}s ({round(sec/60, 1)}m)." + + if op == "swarm.spawn" and isinstance(data, dict): + if not data.get("ok"): + return f"Swarm spawn failed: {data.get('error')}" + return f"Swarm `{data.get('swarm_id')}` spawned with {data.get('count')} worker slots (status: {data.get('status')})." + + if op == "swarm.status" and isinstance(data, dict): + if not data.get("ok"): + return f"Swarm status check failed: {data.get('error')}" + sw = data.get("swarm", {}) + return f"Swarm `{sw.get('swarm_id')}`: {sw.get('status')} ({sw.get('done', 0)}/{sw.get('count', 0)} slots completed)." # General fallback: compact JSON capped to 400 chars s = json.dumps(data) @@ -647,9 +660,15 @@ def process_messages(raw_messages, agent, thread_id, thread_name, last_wm, follo import muse_hybrid if t_ok: clean_msg = format_tool_result_for_chat(op, t_res) - resp_text = f"Tool result (`{op}`):\n{clean_msg}" + resp_text = ( + f"Tool result (`{op}`):\n{clean_msg}\n\n" + f"[Directive: Review result and take next action. When complete, close with [RESULT <job_id>] <summary>]" + ) else: - resp_text = f"Tool error (`{op}`): {t_res}" + resp_text = ( + f"Tool error (`{op}`): {t_res}\n\n" + f"[Directive: Review error and retry or proceed. When complete, close with [RESULT <job_id>] <summary>]" + ) muse_hybrid.send_message(agent, resp_text, thread_id=thread_id, wait=0) except Exception as te: sys.stderr.write(f"warning: failed to post tool response back to thread: {te}\n")