"""Herdr command menu and non-destructive conversation forks.""" from __future__ import annotations from concurrent.futures import ThreadPoolExecutor import curses from dataclasses import dataclass import json import os import subprocess import threading import time from typing import TYPE_CHECKING, Callable, Sequence import uuid import unicodedata from .remotes import ( RemoteEntry, decode_remote_output, parse_remote_session_result, remote_control_command, remote_session_command, ) if TYPE_CHECKING: from .cli import Endpoint # A dedicated command inside the Hub's existing Ctrl+A prefix table. CONTROL_KEY = "h" CONTROL_SHORTCUT = "C-a h" ROUTING_ENV = ( "HERDR_SOCKET_PATH", "HERDR_CLIENT_SOCKET_PATH", "HERDR_SESSION", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID", "HERDR_ACTIVE_PANE_ID", "HERDR_ACTIVE_TAB_ID", "HERDR_ACTIVE_WORKSPACE_ID", ) class ControlError(RuntimeError): """A diagnostic safe to display inside the command menu.""" class ControlCancelled(RuntimeError): """A read-only control request cancelled while closing the popup.""" def object_field(data: dict, name: str) -> dict: value = data.get(name) if not isinstance(value, dict): raise ControlError(f"Herdr 返回缺少 {name} 对象(需要 Herdr 0.9+)") return value def string_field(data: dict, name: str) -> str: value = data.get(name) if not isinstance(value, str) or not value.strip() or "\0" in value: raise ControlError(f"Herdr 返回缺少有效 {name}(需要 Herdr 0.9+)") return value def result_object(payload: object) -> dict: if not isinstance(payload, dict): raise ControlError("Herdr 返回的 JSON 不是对象") if payload.get("error"): raise ControlError(f"Herdr: {json.dumps(payload['error'], ensure_ascii=False)}") return object_field(payload, "result") if "result" in payload else payload @dataclass(frozen=True) class ForkSource: workspace_id: str pane_id: str tab_id: str cwd: str kind: str session_id: str workspace_label: str existing_names: frozenset[str] @property def arguments(self) -> list[str]: if self.kind == "codex": return ["fork", self.session_id] return ["--resume", self.session_id, "--fork-session"] def parse_snapshot(payload: object) -> ForkSource: result = result_object(payload) snapshot = object_field(result, "snapshot") if "snapshot" in result else result workspace_id = string_field(snapshot, "focused_workspace_id") pane_id = string_field(snapshot, "focused_pane_id") panes = snapshot.get("panes") if not isinstance(panes, list): raise ControlError("Herdr snapshot 缺少 panes") matches = [p for p in panes if isinstance(p, dict) and p.get("pane_id") == pane_id] if len(matches) != 1: raise ControlError("找不到唯一的当前 Agent pane") pane = matches[0] if pane.get("workspace_id") != workspace_id: raise ControlError("当前 pane 与 Workspace 不一致,请重新打开菜单") kind = pane.get("agent") if not kind: raise ControlError("当前 pane 没有运行 Agent") if kind not in ("codex", "claude"): raise ControlError(f"暂不支持 Fork {kind};支持 Codex / Claude") session = object_field(pane, "agent_session") if session.get("kind") != "id" or session.get("agent") != kind: raise ControlError("当前 Agent 尚未报告可 Fork 的 conversation ID") session_id = string_field(session, "value") if session_id.startswith("-") or any(ord(c) < 32 for c in session_id): raise ControlError("Agent conversation ID 无效") if kind == "codex": try: uuid.UUID(session_id) except ValueError as error: raise ControlError("Codex conversation ID 不是有效 UUID") from error agents = snapshot.get("agents", []) if not isinstance(agents, list): raise ControlError("Herdr snapshot 的 agents 无效") names = frozenset(a["name"] for a in agents if isinstance(a, dict) and isinstance(a.get("name"), str)) workspaces = snapshot.get("workspaces", []) label = workspace_id if isinstance(workspaces, list): for workspace in workspaces: if isinstance(workspace, dict) and workspace.get("workspace_id") == workspace_id: label = workspace.get("label") or workspace_id return ForkSource(workspace_id, pane_id, string_field(pane, "tab_id"), string_field(pane, "cwd"), kind, session_id, str(label), names) class HerdrControl: def __init__(self, endpoint: Endpoint, herdr_bin: str, ssh_bin: str = "ssh") -> None: self.endpoint = endpoint self.herdr_bin = herdr_bin self.ssh_bin = ssh_bin self.platform: str | None = None def _execute( self, command: Sequence[str], timeout: float, cancel_event: threading.Event | None = None, ) -> subprocess.CompletedProcess: environment = {k: v for k, v in os.environ.items() if k not in ROUTING_ENV} if cancel_event is not None and cancel_event.is_set(): raise ControlCancelled() if cancel_event is not None: try: process = subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, env=environment, ) except OSError as error: raise ControlError(f"无法执行 Herdr 命令: {error}") from error deadline = time.monotonic() + timeout while True: if cancel_event.wait(0.025): process.terminate() try: process.communicate(timeout=0.25) except subprocess.TimeoutExpired: process.kill() process.communicate() raise ControlCancelled() remaining = deadline - time.monotonic() if remaining <= 0: process.kill() process.communicate() raise ControlError( f"命令超时({timeout:g}s);操作可能已完成,请检查 Herdr" ) try: stdout, stderr = process.communicate( timeout=min(0.05, remaining) ) except subprocess.TimeoutExpired: continue return subprocess.CompletedProcess( command, process.returncode, stdout, stderr, ) try: return subprocess.run(command, capture_output=True, check=False, timeout=timeout, env=environment, stdin=subprocess.DEVNULL) except subprocess.TimeoutExpired as error: raise ControlError(f"命令超时({timeout:g}s);操作可能已完成,请检查 Herdr") from error except OSError as error: raise ControlError(f"无法执行 Herdr 命令: {error}") from error def _remote_platform( self, entry: RemoteEntry, cancel_event: threading.Event | None = None, ) -> str: if self.platform is None: for fallback in (False, True): result = self._execute( remote_session_command( self.ssh_bin, entry, posix_fallback=fallback, ), 8, cancel_event, ) if result.returncode != 127 or fallback: break missing = decode_remote_output(result.stderr) + decode_remote_output(result.stdout) if result.returncode != 0 and any(marker in missing.lower() for marker in ( "not recognized", "不是内部或外部命令", "commandnotfoundexception", )): # A Windows shell can lack Herdr on PATH while the standard # per-user executable is installed. This probe is read-only. result = self._execute(remote_control_command(self.ssh_bin, entry, ["session", "list", "--json"], platform="windows"), 8, cancel_event) parsed = parse_remote_session_result(entry, result.returncode, decode_remote_output(result.stdout), decode_remote_output(result.stderr)) if parsed.error: raise ControlError(parsed.error) if parsed.platform not in ("windows", "posix"): raise ControlError("无法从远端 Session 路径识别平台,不能安全执行控制命令") self.platform = parsed.platform return self.platform def call( self, arguments: Sequence[str], *, timeout: float = 10, cancel_event: threading.Event | None = None, ) -> dict: argv = ["--session", self.endpoint.session, *arguments] if self.endpoint.kind == "remote": entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings, self.endpoint.remote_transport) command = remote_control_command(self.ssh_bin, entry, argv, platform=self._remote_platform(entry, cancel_event), unset_environment=ROUTING_ENV) else: command = [self.herdr_bin, *argv] result = self._execute(command, timeout, cancel_event) stdout, stderr = decode_remote_output(result.stdout), decode_remote_output(result.stderr) if result.returncode != 0: raise ControlError((stderr or stdout or f"Herdr exited {result.returncode}").strip()) try: return result_object(json.loads(stdout)) except json.JSONDecodeError as error: raise ControlError(f"Herdr 返回无效 JSON: {error}") from error def source( self, cancel_event: threading.Event | None = None, ) -> ForkSource: return parse_snapshot( self.call(["api", "snapshot"], cancel_event=cancel_event) ) def fork(self, source: ForkSource, progress: Callable[[str], None] = lambda _: None) -> str: name = f"{source.kind}-fork-{uuid.uuid4().hex[:12]}" while name in source.existing_names: name = f"{source.kind}-fork-{uuid.uuid4().hex[:12]}" progress("正在同一 Workspace 创建新 Tab…") # Never delete either tab: a timed-out request may already have succeeded. tab_id = "未知(请检查 Workspace)" try: created = self.call(["tab", "create", "--workspace", source.workspace_id, "--cwd", source.cwd, "--no-focus"]) tab_id = string_field(object_field(created, "tab"), "tab_id") root_pane = object_field(created, "root_pane") pane_id = string_field(root_pane, "pane_id") if tab_id == source.tab_id or pane_id == source.pane_id: raise ControlError("返回的 Tab / root pane 与原 Tab / pane 相同,已拒绝启动") if (root_pane.get("workspace_id") != source.workspace_id or root_pane.get("tab_id") != tab_id): raise ControlError("新 root pane 的 Workspace / Tab 不一致,已拒绝启动") progress(f"正在启动 {source.kind} Fork,等待就绪…") started = self.call(["agent", "start", name, "--kind", source.kind, "--pane", pane_id, "--", *source.arguments], timeout=40) agent = object_field(started, "agent") if (agent.get("pane_id") != pane_id or agent.get("agent") != source.kind or agent.get("interactive_ready") is False or agent.get("launch_pending") is True or agent.get("agent_status") in ("blocked", "error")): raise ControlError("新 Agent 未确认就绪: " + json.dumps(started, ensure_ascii=False)) progress("Fork 已就绪,正在切换 Tab…") self.call(["tab", "focus", tab_id]) except ControlError as error: raise ControlError(f"{error}\n原 Tab 保留;新增 Tab {tab_id} 未自动关闭。请检查后再重试。") from error return tab_id def display_chunks(text: str, width: int) -> list[str]: chunks: list[str] = [] line = "" cells = 0 for character in text: if character == "\n": chunks.append(line) line, cells = "", 0 continue if ord(character) < 32 or ord(character) == 127: character = " " size = 0 if unicodedata.combining(character) else (2 if unicodedata.east_asian_width(character) in ("W", "F") else 1) if cells + size > width and line: chunks.append(line) line, cells = "", 0 line += character cells += size chunks.append(line) return chunks class ControlMenu: """Small action menu; all subprocess work stays off the curses thread.""" ACTIONS = ( ( "fork", "Fork 当前会话到新 Tab", "保留当前 Tab,在同一 Workspace 和工作目录继续", ), ) def __init__(self, control: HerdrControl) -> None: self.control = control def run(self, screen) -> int: # type: ignore[no-untyped-def] import queue messages: queue.SimpleQueue[str] = queue.SimpleQueue() screen.keypad(True) screen.timeout(100) try: curses.set_escdelay(25) curses.curs_set(0) curses.mousemask(curses.BUTTON1_CLICKED | curses.BUTTON1_PRESSED) except (AttributeError, curses.error): pass source: ForkSource | None = None status = "" selected = 0 cancel_loading = threading.Event() with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(self.control.source, cancel_loading) phase = "load" while True: while not messages.empty(): status = messages.get() if future is not None and future.done(): try: value = future.result() if phase == "load": source = value status = "" else: return 0 except ControlCancelled: return 0 except Exception as error: status = str(error) if phase == "fork": source = None # Avoid accidental duplicate forks after ambiguous failure. future = None screen.erase() height, width = screen.getmaxyx() if source is not None: context = ( f"当前:{source.kind.capitalize()} · " f"{source.workspace_label}" ) elif future is not None and phase == "load": context = "正在读取当前页面…" else: context = "无法读取当前页面" self._add(screen, 1, 2, context, curses.A_BOLD) action_top = 3 for index, (_, label, detail) in enumerate(self.ACTIONS): y = action_top + index * 2 enabled = source is not None and future is None attribute = ( curses.A_REVERSE if enabled and index == selected else curses.A_NORMAL if enabled else curses.A_DIM ) self._add(screen, y, 2, f" {label} ", attribute) self._add(screen, y + 1, 4, detail, curses.A_DIM) status_top = action_top + len(self.ACTIONS) * 2 + 1 for offset, line in enumerate( display_chunks(status, max(1, width - 5)) if status else [] ): self._add(screen, status_top + offset, 2, line, curses.A_DIM) footer = ( "操作执行中,请等待结果" if future is not None and phase == "fork" else "Enter 执行 · Esc 关闭" ) self._add(screen, height - 2, 2, footer, curses.A_DIM) screen.refresh() key = screen.getch() if key == 27: if future is not None and phase == "load": cancel_loading.set() return 0 if future is None: return 0 status = "操作执行中;完成后可关闭(避免误以为已取消 Fork)。" elif key in (curses.KEY_UP, curses.KEY_DOWN): selected = (selected + (1 if key == curses.KEY_DOWN else -1)) % len(self.ACTIONS) else: execute = key in (10, 13, curses.KEY_ENTER) if key == curses.KEY_MOUSE: try: _, x, y, _, state = curses.getmouse() action_index = (y - action_top) // 2 execute = ( 1 <= x < width - 1 and 0 <= action_index < len(self.ACTIONS) and y in { action_top + action_index * 2, action_top + action_index * 2 + 1, } and bool( state & ( curses.BUTTON1_CLICKED | curses.BUTTON1_PRESSED ) ) ) if execute: selected = action_index except curses.error: execute = False if execute and future is None and source is not None: phase = "fork" future = executor.submit(self.control.fork, source, messages.put) status = "正在 Fork…" @staticmethod def _add( screen, y: int, x: int, value: str, attribute: int = curses.A_NORMAL, ) -> None: # type: ignore[no-untyped-def] height, width = screen.getmaxyx() if y < 0 or y >= height or x >= width: return chunks = display_chunks(value, max(1, width - x - 1)) try: screen.addstr(y, x, chunks[0] if chunks else "", attribute) except curses.error: pass