feat(tmux): add shared muse tmux socket manager with CLI and agent [TOOL tmux.*] integration
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -24,6 +24,7 @@ show_usage() {
|
||||
echo ""
|
||||
echo "Common commands:"
|
||||
echo " chat [--thread <id>] Launch interactive conversational shell / REPL"
|
||||
echo " tmux <cmd> [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 <id> 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[@]}"
|
||||
|
||||
Executable
+164
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user