diff --git a/bin/exec-constrained.py b/bin/exec-constrained.py index 8450355..cda6267 100755 --- a/bin/exec-constrained.py +++ b/bin/exec-constrained.py @@ -622,6 +622,113 @@ def _cron_timer_create_build(a): return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), 'timer-create', a['name']] +# ------------------------------------------------------- tmux ops +def _tmux_session_name(s): + if not isinstance(s, str) or not re.fullmatch(r'^[a-zA-Z0-9_.-]{1,64}$', s): + raise OpError('session must match ^[a-zA-Z0-9_.-]{1,64}$') + return s + + +def _tmux_send_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'session', 'keys', 'no_enter'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + if not raw.get('session'): + raise OpError('session is required') + if 'keys' not in raw: + raise OpError('keys is required') + return { + 'session': _tmux_session_name(raw['session']), + 'keys': str(raw['keys']), + 'no_enter': bool(raw.get('no_enter', False)), + } + + +def _tmux_send_build(a): + cmd = [sys.executable, os.path.join(BIN_DIR, 'muse-tmux.py'), 'send', a['session'], a['keys']] + if a.get('no_enter'): + cmd.append('--no-enter') + return cmd + + +def _tmux_capture_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'session', 'lines'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + if not raw.get('session'): + raise OpError('session is required') + lines = raw.get('lines', 30) + try: + lines = int(lines) + if lines < 1 or lines > 500: + lines = 30 + except Exception: + lines = 30 + return { + 'session': _tmux_session_name(raw['session']), + 'lines': lines, + } + + +def _tmux_capture_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'muse-tmux.py'), 'capture', a['session'], '--lines', str(a['lines'])] + + +def _tmux_list_validate(raw): + return {} + + +def _tmux_list_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'muse-tmux.py'), 'list'] + + +def _tmux_new_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'session', 'window', 'command'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + if not raw.get('session'): + raise OpError('session is required') + return { + 'session': _tmux_session_name(raw['session']), + 'window': str(raw.get('window', '')) if raw.get('window') else None, + 'command': str(raw.get('command', '')) if raw.get('command') else None, + } + + +def _tmux_new_build(a): + cmd = [sys.executable, os.path.join(BIN_DIR, 'muse-tmux.py'), 'new', a['session']] + if a.get('window'): + cmd.extend(['--window', a['window']]) + if a.get('command'): + cmd.extend(['--command', a['command']]) + return cmd + + +def _tmux_kill_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'session'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + if not raw.get('session'): + raise OpError('session is required') + return {'session': _tmux_session_name(raw['session'])} + + +def _tmux_kill_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'muse-tmux.py'), 'kill', a['session']] + + def _vars_list_validate(raw): if raw not in ({}, None): raise OpError('vars.list takes no required args') @@ -1042,6 +1149,31 @@ OPS = { 'timeout': 30, 'side_effecting': True, 'desc': 'Schedule a delayed autonomous self-followup reminder via transient systemd timer', }, + 'tmux.send': { + 'validate': _tmux_send_validate, 'build': _tmux_send_build, + 'timeout': 15, 'side_effecting': True, + 'desc': 'Send keystrokes to a shared Muse tmux session (/tmp/tmux-muse.sock)', + }, + 'tmux.capture': { + 'validate': _tmux_capture_validate, 'build': _tmux_capture_build, + 'timeout': 15, 'side_effecting': False, + 'desc': 'Capture pane output from a shared Muse tmux session (/tmp/tmux-muse.sock)', + }, + 'tmux.list': { + 'validate': _tmux_list_validate, 'build': _tmux_list_build, + 'timeout': 10, 'side_effecting': False, + 'desc': 'List sessions on shared Muse tmux socket (/tmp/tmux-muse.sock)', + }, + 'tmux.new': { + 'validate': _tmux_new_validate, 'build': _tmux_new_build, + 'timeout': 15, 'side_effecting': True, + 'desc': 'Create a new session on shared Muse tmux socket (/tmp/tmux-muse.sock)', + }, + 'tmux.kill': { + 'validate': _tmux_kill_validate, 'build': _tmux_kill_build, + 'timeout': 15, 'side_effecting': True, + 'desc': 'Kill a session on shared Muse tmux socket (/tmp/tmux-muse.sock)', + }, 'exec.ping': { 'validate': _health_validate, 'build': lambda a: ['/bin/echo', 'PONG'], diff --git a/bin/muse b/bin/muse index 54cff01..557c19c 100755 --- a/bin/muse +++ b/bin/muse @@ -24,6 +24,7 @@ show_usage() { echo "" echo "Common commands:" echo " chat [--thread ] Launch interactive conversational shell / REPL" + echo " tmux [args...] Manage shared Muse tmux sessions (new, send, capture, ls, kill)" echo " status Check agent status, sessions, and unread" echo " threads List active threads and sidechats" echo " history --thread View message history" @@ -106,6 +107,12 @@ if [[ "${POSITIONAL[0]}" == "chat" ]]; then exec python3 "$NETVM_BIN/muse-chat-repl.py" "$ACCOUNT" "${shift_args[@]}" fi +# If subcommand is 'tmux', dispatch to shared muse-tmux manager +if [[ "${POSITIONAL[0]}" == "tmux" ]]; then + shift_args=("${POSITIONAL[@]:1}") + exec python3 "$NETVM_BIN/muse-tmux.py" "${shift_args[@]}" +fi + # Execute command; if auth fails and auto-cdp fails, offer interactive OTP sign-in if running interactively set +e "$NETVM_BIN/muse-cli-node" "$ACCOUNT" "${POSITIONAL[@]}" diff --git a/bin/muse-tmux.py b/bin/muse-tmux.py new file mode 100755 index 0000000..675aab6 --- /dev/null +++ b/bin/muse-tmux.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +muse-tmux.py: Shared tmux socket manager for Muse agents and operators. +Socket location: /tmp/tmux-muse.sock (shared across fleet agents & super). +""" +import sys +import os +import re +import subprocess +import argparse + +SOCKET_PATH = "/tmp/tmux-muse.sock" +TMUX_BIN = "/home/super/.local/bin/tmux" +if not os.path.exists(TMUX_BIN): + TMUX_BIN = "tmux" + +def _run_tmux(*args, capture=True): + cmd = [TMUX_BIN, "-S", SOCKET_PATH] + list(args) + res = subprocess.run(cmd, capture_output=capture, text=True) + return res.returncode, res.stdout, res.stderr + +def cmd_new(args): + session = args.session + window = getattr(args, "window", None) + command = getattr(args, "command", None) + + # Check if session already exists + rc, out, _ = _run_tmux("has-session", "-t", session) + if rc == 0: + print(f"Session '{session}' already exists on shared socket {SOCKET_PATH}.") + return 0 + + t_args = ["new-session", "-d", "-s", session] + if window: + t_args.extend(["-n", window]) + if command: + t_args.append(command) + + rc, out, err = _run_tmux(*t_args) + if rc == 0: + print(f"✔ Created session '{session}' on {SOCKET_PATH}") + return 0 + else: + print(f"Failed to create session: {err.strip() or out.strip()}", file=sys.stderr) + return rc + +def cmd_send(args): + session = args.session + keys = args.keys + enter = not getattr(args, "no_enter", False) + + # Check session + rc, _, _ = _run_tmux("has-session", "-t", session) + if rc != 0: + # Auto-create if not exists + print(f"Notice: Session '{session}' does not exist; creating now...", file=sys.stderr) + _run_tmux("new-session", "-d", "-s", session) + + t_args = ["send-keys", "-t", session, keys] + if enter: + t_args.append("Enter") + + rc, out, err = _run_tmux(*t_args) + if rc == 0: + print(f"✔ Sent keys to '{session}'") + return 0 + else: + print(f"Failed to send keys: {err.strip() or out.strip()}", file=sys.stderr) + return rc + +def cmd_capture(args): + session = args.session + lines = getattr(args, "lines", 30) or 30 + + t_args = ["capture-pane", "-p", "-t", session, "-S", f"-{lines}"] + rc, out, err = _run_tmux(*t_args) + if rc == 0: + # Strip trailing blank lines + clean = out.rstrip() + print(clean) + return 0 + else: + print(f"Failed to capture pane: {err.strip() or out.strip()}", file=sys.stderr) + return rc + +def cmd_list(args): + rc, out, err = _run_tmux("list-sessions") + if rc == 0: + print(out.strip() or "No active sessions.") + return 0 + elif "no server running" in (err or "").lower() or rc == 1: + print(f"No active tmux server on {SOCKET_PATH}.") + return 0 + else: + print(f"Error: {err.strip() or out.strip()}", file=sys.stderr) + return rc + +def cmd_kill(args): + session = args.session + rc, out, err = _run_tmux("kill-session", "-t", session) + if rc == 0: + print(f"✔ Killed session '{session}'") + return 0 + else: + print(f"Error: {err.strip() or out.strip()}", file=sys.stderr) + return rc + +def cmd_attach(args): + session = args.session + cmd = [TMUX_BIN, "-S", SOCKET_PATH, "attach", "-t", session] + # Replace current process for interactive attach + os.execv(TMUX_BIN, cmd) + +def main(): + parser = argparse.ArgumentParser(description="Shared Muse tmux socket manager") + subparsers = parser.add_subparsers(dest="action") + + # list + p_ls = subparsers.add_parser("list", aliases=["ls"], help="List sessions on shared socket") + p_ls.set_defaults(func=cmd_list) + + # new + p_new = subparsers.add_parser("new", help="Create new background session") + p_new.add_argument("session", help="Session name") + p_new.add_argument("--window", "-w", help="Initial window name") + p_new.add_argument("--command", "-c", help="Command to run in session") + p_new.set_defaults(func=cmd_new) + + # send / send-keys + p_send = subparsers.add_parser("send", aliases=["send-keys"], help="Send keys to a session") + p_send.add_argument("session", help="Target session name") + p_send.add_argument("keys", help="Keys / command string to send") + p_send.add_argument("--no-enter", action="store_true", help="Do not send Enter key after keys") + p_send.set_defaults(func=cmd_send) + + # capture + p_cap = subparsers.add_parser("capture", aliases=["cap", "tail"], help="Capture pane output") + p_cap.add_argument("session", help="Target session name") + p_cap.add_argument("--lines", "-n", type=int, default=30, help="Number of scrollback lines to capture (default: 30)") + p_cap.set_defaults(func=cmd_capture) + + # kill + p_kill = subparsers.add_parser("kill", help="Kill a session") + p_kill.add_argument("session", help="Session name") + p_kill.set_defaults(func=cmd_kill) + + # attach + p_att = subparsers.add_parser("attach", help="Attach to a session interactively") + p_att.add_argument("session", help="Session name") + p_att.set_defaults(func=cmd_attach) + + if len(sys.argv) == 1: + parser.print_help() + sys.exit(0) + + args = parser.parse_args() + if not hasattr(args, "func"): + parser.print_help() + sys.exit(1) + + sys.exit(args.func(args)) + +if __name__ == "__main__": + main() diff --git a/bin/response-harvester.py b/bin/response-harvester.py index 8cf7033..5099573 100755 --- a/bin/response-harvester.py +++ b/bin/response-harvester.py @@ -168,6 +168,17 @@ def normalize_native_call(op, args): break if "count" not in args and "n" in args: args["count"] = args.pop("n") + elif op.startswith("tmux."): + if "session" not in args: + for k in ("name", "target", "s"): + if k in args: + args["session"] = args.pop(k) + break + if op == "tmux.send" and "keys" not in args: + for k in ("command", "cmd", "input", "text"): + if k in args: + args["keys"] = args.pop(k) + break return op, args diff --git a/docs/AGENT-TOOLING.md b/docs/AGENT-TOOLING.md index 579feca..e47353b 100644 --- a/docs/AGENT-TOOLING.md +++ b/docs/AGENT-TOOLING.md @@ -72,7 +72,38 @@ box muse send --thread "" --- -## 4. Best Practices & Invariants +## 4. Shared Tmux Socket Tooling (`muse tmux` & `[TOOL tmux.*]`) + +Agents and operators can spawn background sessions and send keystrokes to long-running tasks via the shared socket `/tmp/tmux-muse.sock`. + +### Via Native CLI: +```bash +# List sessions on shared socket +muse tmux list + +# Create or send commands to a background session +muse tmux send "command to execute" + +# Capture recent scrollback output from a session +muse tmux capture --lines 30 + +# Kill a session +muse tmux kill +``` + +### Via Autonomous Agent Directives: +Agents can emit structured tool calls in sidechats: +```text +[TOOL tmux.new {"session": "build-worker", "command": "python3 /srv/worker.py"}] +[TOOL tmux.send {"session": "build-worker", "keys": "ls -la"}] +[TOOL tmux.capture {"session": "build-worker", "lines": 20}] +[TOOL tmux.list {}] +[TOOL tmux.kill {"session": "build-worker"}] +``` + +--- + +## 5. 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.