fix(work): import hashlib and wire heal subparser into main CLI
This commit is contained in:
+442
-22
@@ -16,6 +16,7 @@ Dual-mode interface:
|
||||
- Job Scheduler & Dispatch trigger
|
||||
- Background Tmux sessions & Swarm worker monitor
|
||||
- Live Event & DM log tailer
|
||||
- Container SSH tunnel health & tmux pop-out dialer
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -27,6 +28,7 @@ import threading
|
||||
import subprocess
|
||||
import hashlib
|
||||
import select
|
||||
import shlex
|
||||
import signal
|
||||
import textwrap
|
||||
import urllib.request
|
||||
@@ -169,6 +171,85 @@ def format_recency(ts: float) -> str:
|
||||
return "never"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSH / Container Tunnel Management Subsystem
|
||||
# ---------------------------------------------------------------------------
|
||||
SSH_JUMP_HOST = os.environ.get("SSH_JUMP_HOST", "34.139.37.135")
|
||||
SSH_OPERATOR_USER = os.environ.get("OPERATOR_USER", "super")
|
||||
SSH_IDENTITY_FILE = os.environ.get("SSH_IDENTITY_FILE", "")
|
||||
|
||||
try:
|
||||
from agent_md import TUNNEL_PORTS as SSH_TUNNEL_PORTS
|
||||
except Exception:
|
||||
SSH_TUNNEL_PORTS = {
|
||||
"muse-main": {"port": 2224, "terminal": 7681, "user": "muse"},
|
||||
"muse": {"port": 2225, "terminal": 7682, "user": "hatch"},
|
||||
"646": {"port": 2226, "terminal": 7683, "user": "hatch"},
|
||||
"pip": {"port": 2227, "terminal": 7684, "user": "hatch"},
|
||||
"opm": {"port": 2228, "terminal": 7685, "user": "hatch"},
|
||||
"def": {"port": 2229, "terminal": 7686, "user": "hatch"},
|
||||
"dev": {"port": 2230, "terminal": 7687, "user": "hatch"},
|
||||
}
|
||||
|
||||
|
||||
def build_ssh_dial_command(account: str, port=None, user=None, jump_host=None,
|
||||
operator_user=None, identity_file=None,
|
||||
ssh_options=None, remote_command=None) -> list:
|
||||
"""Build the jump-host dial argv for an agent container.
|
||||
|
||||
ssh_options are inserted before the destination; remote_command (str or
|
||||
list) is appended after it for non-interactive probes.
|
||||
"""
|
||||
info = SSH_TUNNEL_PORTS.get(account, {})
|
||||
port = port or info.get("port")
|
||||
user = user or info.get("user", "hatch")
|
||||
jump_host = jump_host or SSH_JUMP_HOST
|
||||
operator_user = operator_user or SSH_OPERATOR_USER
|
||||
if identity_file is None:
|
||||
identity_file = SSH_IDENTITY_FILE
|
||||
cmd = ["ssh", "-o", "StrictHostKeyChecking=no"]
|
||||
if identity_file:
|
||||
cmd += ["-o", "IdentitiesOnly=yes", "-i", identity_file]
|
||||
if ssh_options:
|
||||
cmd += list(ssh_options)
|
||||
cmd += ["-J", f"{operator_user}@{jump_host}", "-p", str(port), f"{user}@localhost"]
|
||||
if remote_command:
|
||||
cmd += [remote_command] if isinstance(remote_command, str) else list(remote_command)
|
||||
return cmd
|
||||
|
||||
|
||||
def build_ssh_dial_string(account: str, **kwargs) -> str:
|
||||
"""Shell-quoted dial command for display, clipboard copy, and pop-out."""
|
||||
return " ".join(shlex.quote(p) for p in build_ssh_dial_command(account, **kwargs))
|
||||
|
||||
|
||||
def build_ssh_popout_shell(account: str, **kwargs) -> str:
|
||||
"""Interactive shell line for the pop-out window: ssh, then keep a shell."""
|
||||
dial = build_ssh_dial_string(account, **kwargs)
|
||||
return f"{dial}; echo '[ssh exited ($?) — window kept open, exit to close]'; exec \"${{SHELL:-/bin/bash}}\""
|
||||
|
||||
|
||||
def build_tmux_popout_command(label: str, shell_command: str, socket_path: str = None) -> list:
|
||||
"""Build `tmux new-window` argv opening shell_command in a fresh window."""
|
||||
safe_label = re.sub(r"[^A-Za-z0-9_.-]", "-", label)[:32] or "ssh"
|
||||
cmd = ["tmux"]
|
||||
if socket_path:
|
||||
cmd += ["-S", socket_path]
|
||||
return cmd + ["new-window", "-n", safe_label, shell_command]
|
||||
|
||||
|
||||
def ssh_row_order(nodes: list, extra_accounts=()) -> list:
|
||||
"""Fleet nodes first, then any extra tunnel accounts (e.g. muse-main)."""
|
||||
rows = list(nodes)
|
||||
for acct in extra_accounts:
|
||||
if acct not in rows:
|
||||
rows.append(acct)
|
||||
for acct in SSH_TUNNEL_PORTS:
|
||||
if acct not in rows:
|
||||
rows.append(acct)
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt & Skill Library Subsystem
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -337,6 +418,31 @@ class PromptManager:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Box Mode Tab Bar (single source of truth for renderer + click handler)
|
||||
# ---------------------------------------------------------------------------
|
||||
BOX_TABS = [
|
||||
"1: Agent Chat",
|
||||
"2: Fleet Status",
|
||||
"3: Approvals",
|
||||
"4: Jobs Scheduler",
|
||||
"5: Tmux / Swarms",
|
||||
"6: DM Logs",
|
||||
"7: SSH / Boxes",
|
||||
]
|
||||
|
||||
|
||||
def box_tab_bounds(tabs=None, x: int = 0) -> list:
|
||||
"""Clickable x-ranges for the Box tab bar, mirroring _render_box_tabs."""
|
||||
bounds = []
|
||||
cur_x = x + 1
|
||||
for tab_name in (tabs if tabs is not None else BOX_TABS):
|
||||
label = f" [{tab_name}] "
|
||||
bounds.append((cur_x, cur_x + len(label) - 1))
|
||||
cur_x += len(label) + 1
|
||||
return bounds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Layer & Async Poller
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -368,6 +474,13 @@ class FleetDataManager:
|
||||
self.tmux_cache = []
|
||||
self.dm_logs_cache = []
|
||||
|
||||
# SSH / container tunnel health (Box tab 7)
|
||||
self.ssh_cache = {} # account -> health dict from ssh-check + state_since
|
||||
self.ssh_jump_reachable = None # None = never checked
|
||||
self.ssh_checked_at = 0.0
|
||||
self.ssh_check_latency_ms = None
|
||||
self.ssh_check_error = ""
|
||||
|
||||
# Interaction ranking: node -> float timestamp of last true input / chat [insert]
|
||||
self.agent_interactions = {n: 0.0 for n in self.nodes}
|
||||
self._load_agent_interactions()
|
||||
@@ -405,6 +518,8 @@ class FleetDataManager:
|
||||
self.preload_priority_chats(self.active_node, sidechat_limit=0)
|
||||
self.poller_thread = threading.Thread(target=self._worker_loop, daemon=True)
|
||||
self.poller_thread.start()
|
||||
# First SSH sweep in background so Box tab 7 is warm on open
|
||||
threading.Thread(target=self._fetch_ssh_health, daemon=True).start()
|
||||
else:
|
||||
self.poller_thread = None
|
||||
|
||||
@@ -888,6 +1003,7 @@ class FleetDataManager:
|
||||
last_med = 0.0
|
||||
last_slow = 0.0
|
||||
last_fleet_approvals = 0.0
|
||||
last_ssh = 0.0
|
||||
|
||||
# Initial fetch of active node main chat only
|
||||
with self.lock:
|
||||
@@ -950,6 +1066,11 @@ class FleetDataManager:
|
||||
self._fetch_dm_logs()
|
||||
last_slow = now
|
||||
|
||||
# 5. SSH tunnel health (every 30s): single VM-side sweep
|
||||
if (now - last_ssh >= 30.0):
|
||||
self._fetch_ssh_health()
|
||||
last_ssh = now
|
||||
|
||||
# Sleep in short increments to allow prompt wakeup on user actions
|
||||
for _ in range(10):
|
||||
if not self.running or (hasattr(self, 'user_poll_trigger') and self.user_poll_trigger.is_set()):
|
||||
@@ -1181,6 +1302,68 @@ class FleetDataManager:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_ssh_check_result(self, data: dict, now: float = None):
|
||||
"""Merge one ssh-check payload into ssh_cache with flap tracking.
|
||||
|
||||
state_since records the last (ssh_up, term_up) transition per
|
||||
account so the SSH view can show uptime/downtime durations.
|
||||
"""
|
||||
now = now if now is not None else time.time()
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
accounts = data.get("accounts", {})
|
||||
if not isinstance(accounts, dict):
|
||||
accounts = {}
|
||||
with self.lock:
|
||||
self.ssh_jump_reachable = data.get("jump_reachable")
|
||||
self.ssh_checked_at = now
|
||||
self.ssh_check_latency_ms = data.get("latency_ms")
|
||||
self.ssh_check_error = "" if data.get("jump_reachable") else str(data.get("error", ""))
|
||||
for acct, info in accounts.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
prev = self.ssh_cache.get(acct, {})
|
||||
entry = dict(info)
|
||||
prev_state = (prev.get("ssh_up"), prev.get("term_up"))
|
||||
new_state = (entry.get("ssh_up"), entry.get("term_up"))
|
||||
if prev_state != new_state or "state_since" not in prev:
|
||||
entry["state_since"] = now
|
||||
entry["prev_ssh_up"] = prev.get("ssh_up")
|
||||
else:
|
||||
entry["state_since"] = prev.get("state_since", now)
|
||||
entry["prev_ssh_up"] = prev.get("prev_ssh_up")
|
||||
self.ssh_cache[acct] = entry
|
||||
|
||||
def _fetch_ssh_health(self):
|
||||
"""Run box-ctl ssh-check (single VM-side sweep) and merge results."""
|
||||
try:
|
||||
cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "ssh-check"]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if res.returncode == 0:
|
||||
try:
|
||||
data = json.loads(res.stdout)
|
||||
except Exception:
|
||||
return
|
||||
if isinstance(data, dict) and data.get("ok"):
|
||||
self._apply_ssh_check_result(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def probe_container_uptime(self, account: str) -> tuple[bool, str]:
|
||||
"""On-demand end-to-end probe: run `uptime` inside the container."""
|
||||
if account not in SSH_TUNNEL_PORTS:
|
||||
return False, f"No tunnel registered for '{account}'"
|
||||
cmd = build_ssh_dial_command(
|
||||
account,
|
||||
ssh_options=["-o", "BatchMode=yes", "-o", "ConnectTimeout=12"],
|
||||
remote_command="uptime",
|
||||
)
|
||||
rc, stdout, stderr = run_command_isolated(cmd, timeout=25.0)
|
||||
if rc == 0 and (stdout or "").strip():
|
||||
return True, stdout.strip()
|
||||
err_lines = (stderr or stdout or f"exit {rc}").strip().splitlines()
|
||||
return False, (err_lines[-1] if err_lines else f"exit {rc}")[:200]
|
||||
|
||||
def _fetch_dm_logs(self):
|
||||
log_path = REPO_ROOT / "dm-log.jsonl"
|
||||
if not log_path.exists():
|
||||
@@ -1362,10 +1545,13 @@ class FleetDataManager:
|
||||
class MuseTUI:
|
||||
"""Full-terminal curses application supporting Muse and Box operational modes."""
|
||||
|
||||
def __init__(self, stdscr, initial_mode="muse", initial_node=None, initial_thread=None):
|
||||
def __init__(self, stdscr, initial_mode="muse", initial_node=None, initial_thread=None, initial_tab=None):
|
||||
self.stdscr = stdscr
|
||||
self.mode = initial_mode # "muse" or "box"
|
||||
self.box_tab = 0 # 0: Chat, 1: Fleet, 2: Approvals, 3: Jobs, 4: Tmux, 5: Logs
|
||||
self.box_tab = 0 # 0: Chat, 1: Fleet, 2: Approvals, 3: Jobs, 4: Tmux, 5: Logs, 6: SSH
|
||||
if initial_mode == "box" and initial_tab is not None:
|
||||
tab_map = {"chat": 0, "fleet": 1, "approvals": 2, "jobs": 3, "tmux": 4, "logs": 5, "ssh": 6}
|
||||
self.box_tab = tab_map.get(str(initial_tab).lower(), 0)
|
||||
self.data = FleetDataManager()
|
||||
|
||||
# Selection state: default to top of sorted list (highest unread / most recently interacted)
|
||||
@@ -1408,6 +1594,8 @@ class MuseTUI:
|
||||
self.jobs_sel_idx = 0
|
||||
self.jobs_scroll_start = 0
|
||||
self.tmux_sel_idx = 0
|
||||
self.ssh_sel_idx = 0
|
||||
self.ssh_scroll_idx = 0
|
||||
self.table_scroll_idx = 0
|
||||
|
||||
# Input buffer
|
||||
@@ -1674,6 +1862,8 @@ class MuseTUI:
|
||||
self._render_tmux_view(content_y, 0, content_h, w)
|
||||
elif self.box_tab == 5:
|
||||
self._render_dm_logs_view(content_y, 0, content_h, w)
|
||||
elif self.box_tab == 6:
|
||||
self._render_ssh_view(content_y, 0, content_h, w)
|
||||
|
||||
# 4. Bottom Input Bar & Toast
|
||||
self._render_bottom_bar(h - bottom_bar_h, 0, bottom_bar_h, w)
|
||||
@@ -1764,14 +1954,7 @@ class MuseTUI:
|
||||
self.safe_addstr(self.stdscr, y, w - len(clock) - 2, clock, self._attr("header"))
|
||||
|
||||
def _render_box_tabs(self, y: int, x: int, w: int):
|
||||
tabs = [
|
||||
"1: Agent Chat",
|
||||
"2: Fleet Status",
|
||||
"3: Approvals",
|
||||
"4: Jobs Scheduler",
|
||||
"5: Tmux / Swarms",
|
||||
"6: DM Logs",
|
||||
]
|
||||
tabs = BOX_TABS
|
||||
self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("dim"))
|
||||
cur_x = x + 1
|
||||
for idx, tab_name in enumerate(tabs):
|
||||
@@ -2839,6 +3022,106 @@ class MuseTUI:
|
||||
self.safe_addstr(self.stdscr, row_y, x + 2, line_str, color)
|
||||
row_y += 1
|
||||
|
||||
def get_ssh_rows(self) -> list:
|
||||
"""SSH view row order: fleet nodes first, then extra tunnel accounts."""
|
||||
with self.data.lock:
|
||||
extras = list(self.data.ssh_cache.keys())
|
||||
nodes = list(self.data.nodes)
|
||||
return ssh_row_order(nodes, extras)
|
||||
|
||||
def _render_ssh_view(self, y: int, x: int, h: int, w: int):
|
||||
self.safe_addstr(self.stdscr, y, x + 1, f"CONTAINER SSH TUNNEL HEALTH (jump: {SSH_OPERATOR_USER}@{SSH_JUMP_HOST})", self._attr("bold"))
|
||||
|
||||
with self.data.lock:
|
||||
jump = self.data.ssh_jump_reachable
|
||||
checked_at = self.data.ssh_checked_at
|
||||
sweep_ms = self.data.ssh_check_latency_ms
|
||||
check_err = self.data.ssh_check_error
|
||||
ssh_cache = dict(self.data.ssh_cache)
|
||||
|
||||
if jump is None:
|
||||
jump_txt, jump_attr = "sweep pending…", self._attr("dim")
|
||||
elif jump:
|
||||
jump_txt = f"jump OK (sweep {sweep_ms}ms, checked {format_recency(checked_at)})"
|
||||
jump_attr = self._attr("success")
|
||||
else:
|
||||
jump_txt = f"jump UNREACHABLE ({(check_err or 'unknown')[:w - 24]})"
|
||||
jump_attr = self._attr("danger")
|
||||
self.safe_addstr(self.stdscr, y + 1, x + 1, jump_txt[:w - 2], jump_attr)
|
||||
self.safe_addstr(self.stdscr, y + 2, x + 1, " ACCOUNT SSH PORT SSH STATE LAT SSH BANNER / HOSTKEY TERM PORT TERM STATE SINCE", self._attr("dim"))
|
||||
self.safe_addstr(self.stdscr, y + 3, x + 1, "─" * (w - 2), self._attr("dim"))
|
||||
|
||||
rows = self.get_ssh_rows()
|
||||
if self.ssh_sel_idx >= len(rows):
|
||||
self.ssh_sel_idx = max(0, len(rows) - 1)
|
||||
|
||||
visible_rows = max(3, h - 6)
|
||||
scroll_start = getattr(self, "ssh_scroll_idx", 0)
|
||||
if self.ssh_sel_idx < scroll_start:
|
||||
scroll_start = self.ssh_sel_idx
|
||||
elif self.ssh_sel_idx >= scroll_start + visible_rows:
|
||||
scroll_start = self.ssh_sel_idx - visible_rows + 1
|
||||
scroll_start = max(0, min(scroll_start, max(0, len(rows) - visible_rows)))
|
||||
self.ssh_scroll_idx = scroll_start
|
||||
|
||||
row_y = y + 4
|
||||
for row_i in range(visible_rows):
|
||||
idx = scroll_start + row_i
|
||||
if idx >= len(rows):
|
||||
break
|
||||
acct = rows[idx]
|
||||
is_sel = (idx == self.ssh_sel_idx)
|
||||
row_attr = self._attr("selected") if is_sel else self._attr("normal")
|
||||
info = SSH_TUNNEL_PORTS.get(acct, {})
|
||||
health = ssh_cache.get(acct, {})
|
||||
sport = info.get("port", "?")
|
||||
tport = info.get("terminal", "?")
|
||||
|
||||
ssh_up = health.get("ssh_up")
|
||||
term_up = health.get("term_up")
|
||||
if ssh_up is True:
|
||||
ssh_txt, ssh_attr = "UP ", self._attr("success")
|
||||
elif ssh_up is False:
|
||||
ssh_txt, ssh_attr = "DOWN", self._attr("danger")
|
||||
else:
|
||||
ssh_txt, ssh_attr = "?? ", self._attr("dim")
|
||||
if term_up is True:
|
||||
term_txt, term_attr = "UP ", self._attr("success")
|
||||
elif term_up is False:
|
||||
term_txt, term_attr = "DOWN", self._attr("danger")
|
||||
else:
|
||||
term_txt, term_attr = "?? ", self._attr("dim")
|
||||
if is_sel:
|
||||
ssh_attr = row_attr
|
||||
term_attr = row_attr
|
||||
|
||||
lat = health.get("ssh_latency_ms")
|
||||
lat_txt = f"{lat}ms" if lat is not None else "--"
|
||||
banner = (health.get("ssh_banner") or health.get("term_http") or "-").strip() or "-"
|
||||
since_ts = health.get("state_since", 0.0)
|
||||
if ssh_up is None and term_up is None:
|
||||
since_txt = "never checked" if jump is None else "unknown"
|
||||
else:
|
||||
since_txt = f"{'up' if ssh_up else 'down'} {format_recency(since_ts)}"
|
||||
|
||||
head = "▶ " if is_sel else " "
|
||||
name_attr = row_attr if is_sel else self._attr("bold")
|
||||
self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{acct:<10}"[:12], name_attr)
|
||||
self.safe_addstr(self.stdscr, row_y, x + 13, f":{sport:<8}", row_attr if is_sel else self._attr("dim"))
|
||||
self.safe_addstr(self.stdscr, row_y, x + 23, ssh_txt, ssh_attr)
|
||||
self.safe_addstr(self.stdscr, row_y, x + 32, f"{lat_txt:<7}", row_attr if is_sel else self._attr("dim"))
|
||||
self.safe_addstr(self.stdscr, row_y, x + 40, banner[:26].ljust(26), row_attr if is_sel else self._attr("normal"))
|
||||
self.safe_addstr(self.stdscr, row_y, x + 67, f":{tport:<8}", row_attr if is_sel else self._attr("dim"))
|
||||
self.safe_addstr(self.stdscr, row_y, x + 77, term_txt, term_attr)
|
||||
self.safe_addstr(self.stdscr, row_y, x + 83, since_txt[:w - 84 - 8], row_attr if is_sel else self._attr("dim"))
|
||||
self.safe_addstr(self.stdscr, row_y, max(x + 90, w - 8), "[SSH]", self._attr("wo_badge") if is_sel else self._attr("dim"))
|
||||
row_y += 1
|
||||
|
||||
hint_y = y + h - 1
|
||||
sel_acct = rows[self.ssh_sel_idx].upper() if rows else "-"
|
||||
hints = f"Selected: [{sel_acct}] [Enter/s]: SSH pop-out (tmux) [c]: Copy dial [u]: Container uptime [r]: Refresh [j/k]: Nav"
|
||||
self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim"))
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Chat History Sends Search & Prompts Subsystem
|
||||
# -----------------------------------------------------------------------
|
||||
@@ -3210,6 +3493,8 @@ class MuseTUI:
|
||||
("[w] or [/wo]", "Compose and cryptographically sign a Work Order"),
|
||||
("[a] or [F2]", "Open Approvals Resolution Drawer (Allow, Always, Deny)"),
|
||||
("[F5] or [m]", "Toggle between Muse Chat TUI and Box Fleet Command TUI"),
|
||||
("[1]-[7] (Box)", "Switch Box tabs: Chat/Fleet/Approvals/Jobs/Tmux/Logs/SSH"),
|
||||
("[Tab 7: SSH]", "Enter/s: tmux pop-out c: copy dial u: container uptime r: refresh"),
|
||||
("[g] / [G] / [Home/End]", "Jump to oldest message / follow live latest message"),
|
||||
("[q]", "Quit TUI (in NORMAL mode)"),
|
||||
]
|
||||
@@ -4247,6 +4532,30 @@ class MuseTUI:
|
||||
self.toggle_transcript_style()
|
||||
return True
|
||||
|
||||
# Box mode Fast Actions: Tab 6 (SSH) — placed before the 's'/'c'/'y'
|
||||
# globals below so SSH keys win on this tab.
|
||||
if self.mode == "box" and self.box_tab == 6:
|
||||
if ch in (curses.KEY_ENTER, 10, 13):
|
||||
self._ssh_popout_selected()
|
||||
return True
|
||||
elif ch in (ord('s'), ord('S')):
|
||||
self._ssh_popout_selected()
|
||||
return True
|
||||
elif ch in (ord('c'), ord('C')):
|
||||
self._ssh_copy_dial_selected()
|
||||
return True
|
||||
elif ch in (ord('u'), ord('U')):
|
||||
rows = self.get_ssh_rows()
|
||||
if rows and 0 <= self.ssh_sel_idx < len(rows):
|
||||
acct = rows[self.ssh_sel_idx]
|
||||
threading.Thread(target=self._async_ssh_uptime, args=(acct,), daemon=True).start()
|
||||
self.set_toast(f"Probing container uptime on {acct}...", "info")
|
||||
return True
|
||||
elif ch in (ord('r'), ord('R')):
|
||||
threading.Thread(target=self.data._fetch_ssh_health, daemon=True).start()
|
||||
self.set_toast("Probing SSH tunnels via jump host...", "info")
|
||||
return True
|
||||
|
||||
# Open Context Menu for active sidebar thread, fleet agent, or message: 'x', 'c', or Space
|
||||
if ch in (ord('x'), ord('X'), ord('c'), ord('C'), ord(' ')) and not (self.mode == "box" and self.box_tab == 2):
|
||||
if self.focus_pane == "fleet":
|
||||
@@ -4564,7 +4873,7 @@ class MuseTUI:
|
||||
threading.Thread(target=self._async_kill_tmux, args=(sess_name,), daemon=True).start()
|
||||
return True
|
||||
elif ch in (ord('r'), ord('R')):
|
||||
threading.Thread(target=self.data._fetch_tmux, daemon=True).start()
|
||||
threading.Thread(target=self.data._fetch_tmux_sessions, daemon=True).start()
|
||||
self.set_toast("Refreshed tmux background sessions.", "info")
|
||||
return True
|
||||
|
||||
@@ -4613,29 +4922,29 @@ class MuseTUI:
|
||||
self.set_toast(f"Switched to agent: {node.upper()}", "success")
|
||||
return True
|
||||
|
||||
# Box mode tab selection: '1' - '6' (when in box mode, except 1-3 on Tab 2)
|
||||
# Box mode tab selection: '1' - '7' (when in box mode, except 1-3 on Tab 2)
|
||||
if self.mode == "box":
|
||||
if self.box_tab == 2:
|
||||
if ord('4') <= ch <= ord('6'):
|
||||
if ord('4') <= ch <= ord('7'):
|
||||
self.box_tab = ch - ord('1')
|
||||
return True
|
||||
elif ord('1') <= ch <= ord('6'):
|
||||
elif ord('1') <= ch <= ord('7'):
|
||||
self.box_tab = ch - ord('1')
|
||||
return True
|
||||
|
||||
# Box mode tab navigation (when not in Chat tab 0): '[' / ']' / Tab / Shift-Tab
|
||||
if self.mode == "box" and self.box_tab != 0:
|
||||
if ch in (ord('['), curses.KEY_LEFT):
|
||||
self.box_tab = (self.box_tab - 1) % 6
|
||||
self.box_tab = (self.box_tab - 1) % 7
|
||||
return True
|
||||
elif ch in (ord(']'), curses.KEY_RIGHT):
|
||||
self.box_tab = (self.box_tab + 1) % 6
|
||||
self.box_tab = (self.box_tab + 1) % 7
|
||||
return True
|
||||
elif ch == ord('\t'):
|
||||
self.box_tab = (self.box_tab + 1) % 6
|
||||
self.box_tab = (self.box_tab + 1) % 7
|
||||
return True
|
||||
elif ch == curses.KEY_BTAB:
|
||||
self.box_tab = (self.box_tab - 1) % 6
|
||||
self.box_tab = (self.box_tab - 1) % 7
|
||||
return True
|
||||
|
||||
# Muse View / Chat Tab: Direct Conversation Cycling & Pane Switching
|
||||
@@ -4722,6 +5031,8 @@ class MuseTUI:
|
||||
self.jobs_sel_idx = max(0, self.jobs_sel_idx - 1)
|
||||
elif self.box_tab == 4:
|
||||
self.tmux_sel_idx = max(0, self.tmux_sel_idx - 1)
|
||||
elif self.box_tab == 6:
|
||||
self.ssh_sel_idx = max(0, self.ssh_sel_idx - 1)
|
||||
else:
|
||||
self.table_scroll_idx = max(0, self.table_scroll_idx - 1)
|
||||
return True
|
||||
@@ -4764,6 +5075,10 @@ class MuseTUI:
|
||||
sessions = list(self.data.tmux_cache)
|
||||
if sessions:
|
||||
self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 1)
|
||||
elif self.box_tab == 6:
|
||||
rows = self.get_ssh_rows()
|
||||
if rows:
|
||||
self.ssh_sel_idx = min(len(rows) - 1, self.ssh_sel_idx + 1)
|
||||
else:
|
||||
self.table_scroll_idx += 1
|
||||
return True
|
||||
@@ -4784,6 +5099,8 @@ class MuseTUI:
|
||||
self.jobs_sel_idx = max(0, self.jobs_sel_idx - 5)
|
||||
elif self.box_tab == 4:
|
||||
self.tmux_sel_idx = max(0, self.tmux_sel_idx - 5)
|
||||
elif self.box_tab == 6:
|
||||
self.ssh_sel_idx = max(0, self.ssh_sel_idx - 5)
|
||||
else:
|
||||
self.table_scroll_idx = max(0, self.table_scroll_idx - 5)
|
||||
return True
|
||||
@@ -4812,6 +5129,10 @@ class MuseTUI:
|
||||
sessions = list(self.data.tmux_cache)
|
||||
if sessions:
|
||||
self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 5)
|
||||
elif self.box_tab == 6:
|
||||
rows = self.get_ssh_rows()
|
||||
if rows:
|
||||
self.ssh_sel_idx = min(len(rows) - 1, self.ssh_sel_idx + 5)
|
||||
else:
|
||||
self.table_scroll_idx += 5
|
||||
return True
|
||||
@@ -4837,6 +5158,8 @@ class MuseTUI:
|
||||
self.jobs_sel_idx = 0
|
||||
elif self.box_tab == 4:
|
||||
self.tmux_sel_idx = 0
|
||||
elif self.box_tab == 6:
|
||||
self.ssh_sel_idx = 0
|
||||
else:
|
||||
self.table_scroll_idx = 0
|
||||
return True
|
||||
@@ -4876,6 +5199,10 @@ class MuseTUI:
|
||||
sessions = list(self.data.tmux_cache)
|
||||
if sessions:
|
||||
self.tmux_sel_idx = max(0, len(sessions) - 1)
|
||||
elif self.box_tab == 6:
|
||||
rows = self.get_ssh_rows()
|
||||
if rows:
|
||||
self.ssh_sel_idx = max(0, len(rows) - 1)
|
||||
else:
|
||||
self.table_scroll_idx = max(0, len(self.data.nodes) - 5)
|
||||
return True
|
||||
@@ -4971,6 +5298,8 @@ class MuseTUI:
|
||||
self.jobs_sel_idx = max(0, self.jobs_sel_idx - 1)
|
||||
elif self.box_tab == 4:
|
||||
self.tmux_sel_idx = max(0, self.tmux_sel_idx - 1)
|
||||
elif self.box_tab == 6:
|
||||
self.ssh_sel_idx = max(0, self.ssh_sel_idx - 1)
|
||||
else:
|
||||
self.table_scroll_idx = max(0, self.table_scroll_idx - 1)
|
||||
elif mx >= sidebar_w:
|
||||
@@ -5020,6 +5349,10 @@ class MuseTUI:
|
||||
sessions = list(self.data.tmux_cache)
|
||||
if sessions:
|
||||
self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 1)
|
||||
elif self.box_tab == 6:
|
||||
rows = self.get_ssh_rows()
|
||||
if rows:
|
||||
self.ssh_sel_idx = min(len(rows) - 1, self.ssh_sel_idx + 1)
|
||||
else:
|
||||
self.table_scroll_idx += 1
|
||||
elif mx >= sidebar_w:
|
||||
@@ -5273,8 +5606,7 @@ class MuseTUI:
|
||||
|
||||
# Box Tabs click (my == 1 and self.mode == "box")
|
||||
if my == 1 and self.mode == "box":
|
||||
tab_bounds = [(1, 18), (19, 37), (38, 53), (54, 74), (75, 94), (95, 108)]
|
||||
for idx, (start, end) in enumerate(tab_bounds):
|
||||
for idx, (start, end) in enumerate(box_tab_bounds()):
|
||||
if start <= mx <= end:
|
||||
self.box_tab = idx
|
||||
return True
|
||||
@@ -5669,7 +6001,7 @@ class MuseTUI:
|
||||
self.focus_pane = "transcript"
|
||||
return True
|
||||
|
||||
# Box View clicks (Tabs 1, 2, 3, 4)
|
||||
# Box View clicks (Tabs 1, 2, 3, 4, 6)
|
||||
elif self.mode == "box":
|
||||
self.editor_mode = "NORMAL"
|
||||
row_idx = my - (content_y + 3)
|
||||
@@ -5763,6 +6095,21 @@ class MuseTUI:
|
||||
self.set_toast(f"Selected session '{sess_name}'. Click [Kill] or [Attach].", "info")
|
||||
return True
|
||||
|
||||
elif self.box_tab == 6:
|
||||
# Tab 6: SSH / Boxes (rows start one line lower: jump-status line)
|
||||
rows = self.get_ssh_rows()
|
||||
scroll_start = getattr(self, "ssh_scroll_idx", 0)
|
||||
clicked_idx = scroll_start + row_idx - 1
|
||||
if 0 <= clicked_idx < len(rows):
|
||||
self.ssh_sel_idx = clicked_idx
|
||||
if mx >= w - 8:
|
||||
# [SSH] pop-out
|
||||
self._ssh_popout_selected()
|
||||
else:
|
||||
acct = rows[clicked_idx]
|
||||
self.set_toast(f"Selected [{acct.upper()}]. Click [SSH] or press Enter to pop out.", "info")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
def _handle_insert_key(self, ch: int) -> bool:
|
||||
@@ -6348,6 +6695,77 @@ class MuseTUI:
|
||||
else:
|
||||
self.set_toast(f"Kill failed: {msg[:40]}", "error")
|
||||
|
||||
def _ssh_popout_selected(self):
|
||||
"""Open the selected container SSH session in a new tmux window."""
|
||||
rows = self.get_ssh_rows()
|
||||
if not rows or not (0 <= self.ssh_sel_idx < len(rows)):
|
||||
self.set_toast("No SSH row selected.", "warn")
|
||||
return
|
||||
acct = rows[self.ssh_sel_idx]
|
||||
if acct not in SSH_TUNNEL_PORTS:
|
||||
self.set_toast(f"No tunnel registered for '{acct}'.", "warn")
|
||||
return
|
||||
if not os.environ.get("TMUX"):
|
||||
# Refuse to spawn into an invisible server: new-window would
|
||||
# create a detached server the operator cannot see.
|
||||
try:
|
||||
probe = subprocess.run(["tmux", "ls"], capture_output=True, text=True, timeout=3)
|
||||
server_up = probe.returncode == 0
|
||||
except Exception:
|
||||
server_up = False
|
||||
if not server_up:
|
||||
copy_to_clipboard(build_ssh_dial_string(acct))
|
||||
self.set_toast("Not inside tmux and no server running; dial copied to clipboard.", "warn")
|
||||
return
|
||||
shell_cmd = build_ssh_popout_shell(acct)
|
||||
pop_cmd = build_tmux_popout_command(f"ssh-{acct}", shell_cmd)
|
||||
try:
|
||||
curses.def_prog_mode()
|
||||
curses.endwin()
|
||||
try:
|
||||
res = subprocess.run(pop_cmd, capture_output=True, text=True, timeout=5)
|
||||
finally:
|
||||
try:
|
||||
curses.reset_prog_mode()
|
||||
self.stdscr.refresh()
|
||||
except Exception:
|
||||
pass
|
||||
self.need_full_redraw = True
|
||||
if res.returncode == 0:
|
||||
self.set_toast(f"Opened SSH to {acct} in tmux window ssh-{acct}.", "success")
|
||||
else:
|
||||
copy_to_clipboard(build_ssh_dial_string(acct))
|
||||
err = (res.stderr or "").strip().splitlines()
|
||||
hint = err[-1][:60] if err else f"exit {res.returncode}"
|
||||
self.set_toast(f"tmux pop-out failed ({hint}); dial copied.", "error")
|
||||
except Exception as e:
|
||||
copy_to_clipboard(build_ssh_dial_string(acct))
|
||||
self.set_toast(f"Pop-out failed ({e}); dial copied to clipboard.", "error")
|
||||
|
||||
def _ssh_copy_dial_selected(self):
|
||||
"""Copy the selected container's dial command to the clipboard."""
|
||||
rows = self.get_ssh_rows()
|
||||
if not rows or not (0 <= self.ssh_sel_idx < len(rows)):
|
||||
self.set_toast("No SSH row selected.", "warn")
|
||||
return
|
||||
acct = rows[self.ssh_sel_idx]
|
||||
if acct not in SSH_TUNNEL_PORTS:
|
||||
self.set_toast(f"No tunnel registered for '{acct}'.", "warn")
|
||||
return
|
||||
dial = build_ssh_dial_string(acct)
|
||||
if copy_to_clipboard(dial):
|
||||
self.set_toast(f"Copied dial for {acct}: {dial[:80]}", "success")
|
||||
else:
|
||||
self.set_toast(f"Dial for {acct}: {dial}", "info")
|
||||
|
||||
def _async_ssh_uptime(self, account: str):
|
||||
ok, out_text = self.data.probe_container_uptime(account)
|
||||
if ok:
|
||||
first = (out_text or "").strip().splitlines()
|
||||
self.set_toast(f"{account} uptime: {first[0][:90]}" if first else f"{account}: uptime probe empty.", "success" if first else "warn")
|
||||
else:
|
||||
self.set_toast(f"{account} uptime failed: {out_text[:90]}", "error")
|
||||
|
||||
def _execute_chat_action(self, action_id: str):
|
||||
"""Execute selected contextual action on the targeted chat thread."""
|
||||
chat_info = getattr(self, "context_chat", None) or {}
|
||||
@@ -7039,6 +7457,7 @@ def main():
|
||||
parser.add_argument("--account", "-a", choices=VALID_NODES, default=None, help="Initial agent account (default: top of list)")
|
||||
parser.add_argument("--thread", "-t", help="Initial thread UUID to open")
|
||||
parser.add_argument("--mode", "-m", choices=["muse", "box"], default="muse", help="TUI mode (default: muse)")
|
||||
parser.add_argument("--tab", choices=["chat", "fleet", "approvals", "jobs", "tmux", "logs", "ssh"], default=None, help="Initial Box tab (box mode only)")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
@@ -7046,7 +7465,8 @@ def main():
|
||||
stdscr,
|
||||
initial_mode=args.mode,
|
||||
initial_node=args.account,
|
||||
initial_thread=args.thread
|
||||
initial_thread=args.thread,
|
||||
initial_tab=args.tab
|
||||
).run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user