Add Herdr agent fork command menu

This commit is contained in:
2026-09-18 09:07:04 +00:00
parent d6697d46f8
commit 6e420b2db5
7 changed files with 897 additions and 4 deletions
+27 -1
View File
@@ -27,7 +27,7 @@ sidebar 插入 UI,也不能让 thin client 从一个 server 原地改连另一
- Linux
- Python 3.11+
- tmux 3.4+
- Herdr 0.8.2+
- Herdr 0.9+(本地和需要 Fork 的远端目标均需支持 0.9 的控制 API)
- OpenSSH client
检查当前环境:
@@ -93,6 +93,7 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外
| 操作 | 快捷键 / 命令 |
| --- | --- |
| 点击切换 | 鼠标点击顶部 session |
| 打开 Herdr 命令菜单 | `Ctrl+A h` |
| 打开 Session 弹窗 | 点击左上角 `Sessions` 或 `Ctrl+A g` |
| 上一个/下一个 window | `Ctrl+A p` / `Ctrl+A n` |
| 按编号切换 | `Ctrl+A 0..9` |
@@ -105,6 +106,31 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外
| 重新进入 Hub | `herdr-hub` |
| 停止 Hub clients | `herdr-hub stop` |
### Fork 当前 Agent 到新 Tab
按 **Ctrl+A h** 打开独立的 Herdr 命令菜单,选择“Fork 当前会话到新 Tab”,
按 Enter 或单击执行,Esc 关闭。菜单只显示当前 Agent、Workspace、主操作和必要的状态信息。
操作正在执行时需等待结果后再关闭,以免将关闭弹窗误认为取消已经发送的请求。
Sessions 弹窗仍使用 `Ctrl+A g`;两个菜单都使用 Hub 外层的 `Ctrl+A` 前缀。
快捷键集中定义在 `herdr_hub/control.py` 的 `CONTROL_KEY` 常量中。
Fork 复制的是 **Codex / Claude conversation**,并保留正在运行的原 Agent 和原 Tab。
它在同一个 Herdr Workspace、相同 cwd 新建 Tab,使用当前 pane 已报告的 conversation ID:
Codex 执行 `codex fork UUID`;Claude 执行 `claude --resume SESSION_ID --fork-session`。
新 Agent 被 Herdr 确认为就绪后才切换焦点。它不会新建外层 Herdr named session,
也不会创建 Git worktree;两个 Agent 会编辑同一工作目录,需自行协调并发修改。
支持本地 default / named session,以及通过 SSH 控制的远端 native / Windows SSH endpoint。
控制目标取自触发菜单的 Hub window metadata,不使用 popup 继承的 Herdr socket 或 pane 环境变量。
目标必须运行 Herdr 0.9+,并安装对应 Agent;当前 pane 必须已报告可用的 Agent session ID。
远端控制使用非交互 SSH(需要已信任的主机密钥和可用认证);Windows 需要 PowerShell。
启动 blocked、失败、超时或响应不完整时,菜单保留错误信息,**不会自动关闭任何 Tab**。
超时并不代表远端没有创建或启动成功,请先检查提示中的新 Tab,再重新打开菜单重试。
CLI 入口是 `herdr-hub control`。弹窗打开时会锁定 Hub 当前激活的 managed window,
再从其 Endpoint 对应的 Herdr server 读取当前页面的 Agent conversation。
更新后运行 `herdr-hub refresh` 配置现有 Hub 的快捷键。`herdr-hub doctor` 会显示入口和版本要求。
## 远端 Session 弹窗
弹窗由 tmux `display-popup` 和 Hub 内置选择器组成,不依赖 fzf。它会立即显示已打开的
+37 -2
View File
@@ -22,6 +22,7 @@ from typing import Callable, Iterable, Mapping, Sequence
from urllib.parse import quote
from .aliases import AliasRegistry, AliasRegistryError, default_alias_registry_path
from .control import CONTROL_KEY, CONTROL_SHORTCUT, ControlMenu, HerdrControl
from .domain import validate_session_name
from .remotes import (
RegistryError,
@@ -581,7 +582,7 @@ class TmuxHub:
["set-option", "-g", "mouse", "on"],
["set-option", "-g", "focus-events", "on"],
["set-option", "-g", "status-left-length", "18"],
["set-option", "-g", "status-right-length", "42"],
["set-option", "-g", "status-right-length", "64"],
[
"set-option",
"-g",
@@ -596,7 +597,7 @@ class TmuxHub:
"status-right",
"#{?#{<:#{client_width},80},,"
"#[dim]#{?pane_dead,连接已断开 · C-a R 重连,"
"C-a g choose · q close · r refresh}#[default]}",
f"{CONTROL_SHORTCUT} commands · C-a g choose · q close · r refresh}}#[default]}}",
],
["set-option", "-g", "status-style", "bg=colour235,fg=colour250"],
["set-window-option", "-g", "window-status-separator", ""],
@@ -638,12 +639,18 @@ class TmuxHub:
close = shlex.join(
[self.python_bin, self.script_path, "close", "--window", "#{window_id}"]
)
control = shlex.join([self.python_bin, self.script_path, "control"])
confirm_close = shlex.join(["run-shell", "-b", close])
# Remove tmux's default/legacy '&' binding when reconfiguring both new
# and already-running Hub servers.
self.run(["unbind-key", "-T", "prefix", "&"], check=False)
self.run(["unbind-key", "-T", "prefix", "Q"], check=False)
# Remove the short-lived global binding used by the first control-menu
# prototype when reconfiguring an already-running Hub.
self.run(["unbind-key", "-T", "root", "C-g"], check=False)
binding_commands = [
["bind-key", "-T", "prefix", CONTROL_KEY, "display-popup", "-EE",
"-w", "80%", "-h", "50%", "-T", "Herdr 命令", control],
["bind-key", "-T", "prefix", "C-a", "send-prefix"],
["bind-key", "-T", "prefix", "r", "run-shell", "-b", refresh],
[
@@ -1423,6 +1430,27 @@ def command_choose(arguments: argparse.Namespace) -> int:
return 0
def command_control(arguments: argparse.Namespace) -> int:
hub = make_hub(arguments, load_aliases=False)
windows = hub.managed_windows()
if arguments.window:
window = next(
(item for item in windows if item.window_id == arguments.window),
None,
)
else:
active = [item for item in windows if item.active]
window = active[0] if len(active) == 1 else None
if window is None or window.endpoint is None:
raise HubError("无法确定当前 Herdr Hub Tab,请回到 Session 页面后重试")
if window.dead:
raise HubError("当前 Herdr 连接已断开,请先按 Ctrl+A R 重连")
# Endpoint metadata is authoritative; popup environment may belong to another server.
binary = resolve_herdr() if window.endpoint.kind == "local" else "herdr"
control = HerdrControl(window.endpoint, binary, hub.ssh_bin)
return curses.wrapper(ControlMenu(control).run)
def command_remote(arguments: argparse.Namespace) -> int:
registry = make_registry()
action = arguments.remote_command
@@ -1493,6 +1521,10 @@ def command_stop(arguments: argparse.Namespace) -> int:
def command_doctor(arguments: argparse.Namespace) -> int:
failures = 0
print(
f"control: {CONTROL_SHORTCUT} (Hub prefix); "
"Fork requires Herdr 0.9+ on target"
)
print(f"python: {sys.version.split()[0]} ({sys.executable})")
print(f"curses: {getattr(curses, 'version', 'available')}")
for binary in ["tmux", "herdr", "ssh"]:
@@ -1566,6 +1598,8 @@ def build_parser() -> argparse.ArgumentParser:
help="remote attach transport; Windows targets require ssh",
)
control_parser = subparsers.add_parser("control", help="open Herdr commands for the current Hub window")
control_parser.add_argument("--window", help="originating tmux window ID")
subparsers.add_parser("choose", help="browse and open local or remote sessions")
remote_parser = subparsers.add_parser("remote", help="manage remembered remote targets")
remote_subparsers = remote_parser.add_subparsers(dest="remote_command")
@@ -1609,6 +1643,7 @@ def main(argv: Sequence[str] | None = None) -> int:
"refresh": command_refresh,
"open": command_open,
"choose": command_choose,
"control": command_control,
"remote": command_remote,
"reconnect": command_reconnect,
"close": command_close,
+457
View File
@@ -0,0 +1,457 @@
"""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
+55
View File
@@ -708,3 +708,58 @@ class RemoteQueryPool:
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
def remote_control_command(
ssh_bin: str,
entry: RemoteEntry,
arguments: Sequence[str],
*,
platform: str,
unset_environment: Sequence[str] = (),
) -> list[str]:
"""Build one non-replayed mutation with platform-specific argv encoding.
Windows SSH commonly uses cmd.exe; only a base64 PowerShell script crosses
that shell. ProcessStartInfo then receives Windows-quoted native arguments.
"""
import base64
import re
import shlex
if any(not re.fullmatch(r"[A-Z_][A-Z_0-9]*", key) for key in unset_environment):
raise ValueError("invalid environment variable name")
if any("\0" in argument for argument in arguments):
raise ValueError("NUL in remote argument")
if platform == "posix":
unset = "".join(f"unset {key}; " for key in unset_environment)
argv = shlex.join(arguments)
script = (
unset + 'if [ -x "$HOME/.local/bin/herdr" ]; then '
f'exec "$HOME/.local/bin/herdr" {argv}; '
'elif command -v herdr >/dev/null 2>&1; then '
f"exec herdr {argv}; "
"else echo 'herdr is not installed on the remote host' >&2; exit 127; fi"
)
elif platform == "windows":
def literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
unset = "".join(f"Remove-Item Env:{key} -ErrorAction SilentlyContinue; " for key in unset_environment)
script_text = (
"$ErrorActionPreference='Stop'; " + unset
+ "$exe=Join-Path $HOME '.local\\bin\\herdr.exe'; "
"if (!(Test-Path -LiteralPath $exe)) { $exe=(Get-Command herdr -ErrorAction Stop).Source }; "
"$p=New-Object System.Diagnostics.Process; "
"$p.StartInfo.FileName=$exe; "
"$p.StartInfo.UseShellExecute=$false; "
"$p.StartInfo.Arguments=" + literal(subprocess.list2cmdline(arguments)) + "; "
"$null=$p.Start(); $p.WaitForExit(); exit $p.ExitCode"
)
encoded = base64.b64encode(script_text.encode("utf-16le")).decode("ascii")
script = "powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand " + encoded
else:
raise ValueError("unknown remote platform")
return [ssh_bin, "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "ConnectionAttempts=1", "-o", "ConnectTimeout=4",
*ssh_destination_arguments(entry.target), script]
+2 -1
View File
@@ -588,8 +588,9 @@ class SessionSelector:
screen.keypad(True)
screen.timeout(100)
try:
curses.set_escdelay(25)
curses.curs_set(0)
except curses.error:
except (AttributeError, curses.error):
pass
try:
curses.use_default_colors()
+304
View File
@@ -0,0 +1,304 @@
from __future__ import annotations
import argparse
import base64
import curses
import json
import os
import subprocess
import threading
import time
import unittest
from unittest.mock import Mock, patch
from herdr_hub.cli import Endpoint, HubError, ManagedWindow, build_parser, command_control
from herdr_hub.control import (
ControlCancelled,
ControlError,
ControlMenu,
HerdrControl,
parse_snapshot,
)
from herdr_hub.remotes import RemoteEntry, remote_control_command
SESSION_ID = "0198f891-d531-745e-96f4-ef7d9092298b"
def snapshot(kind="codex"):
return {"id": "request", "result": {"snapshot": {
"focused_workspace_id": "w1", "focused_pane_id": "w1:p1",
"panes": [{"pane_id": "w1:p1", "workspace_id": "w1", "tab_id": "t1", "agent": kind,
"cwd": "/tmp/project with spaces", "agent_session": {
"agent": kind, "kind": "id", "value": SESSION_ID}}],
"agents": [{"name": "existing-agent"}],
"workspaces": [{"workspace_id": "w1", "label": "Project"}],
}}}
def completed(payload, code=0):
return subprocess.CompletedProcess([], code, json.dumps(payload).encode(), b"")
class SnapshotTests(unittest.TestCase):
def test_focused_pane_and_exact_native_arguments(self):
for kind, args in (("codex", ["fork", SESSION_ID]),
("claude", ["--resume", SESSION_ID, "--fork-session"])):
with self.subTest(kind=kind):
source = parse_snapshot(snapshot(kind))
self.assertEqual(source.arguments, args)
self.assertEqual(source.workspace_id, "w1")
self.assertEqual(source.workspace_label, "Project")
self.assertEqual(source.existing_names, {"existing-agent"})
def test_invalid_snapshots_fail_before_mutation(self):
for field, value in (("agent", None), ("agent", "gemini"), ("cwd", None),
("workspace_id", "other"), ("agent_session", None),
("agent_session", {"agent": "codex", "kind": "id", "value": "wrong"}),
("agent_session", {"agent": "codex", "kind": "path", "value": "x"})):
payload = snapshot()
payload["result"]["snapshot"]["panes"][0][field] = value
with self.subTest(field=field, value=value), self.assertRaises(ControlError):
parse_snapshot(payload)
for field in ("focused_workspace_id", "focused_pane_id", "panes"):
payload = snapshot()
del payload["result"]["snapshot"][field]
with self.subTest(field=field), self.assertRaises(ControlError):
parse_snapshot(payload)
class ControlTests(unittest.TestCase):
def test_local_explicit_session_and_clean_environment(self):
for session in ("default", "named"):
with self.subTest(session=session), patch.dict(os.environ, {
"HERDR_SOCKET_PATH": "/wrong/socket", "HERDR_SESSION": "wrong",
"HERDR_PANE_ID": "wrong", "HERDR_CLIENT_SOCKET_PATH": "/wrong/client",
}), patch("herdr_hub.control.subprocess.run", return_value=completed(snapshot())) as run:
source = HerdrControl(Endpoint.local(session), "/fake/herdr").source()
self.assertEqual(source.kind, "codex")
self.assertEqual(run.call_args.args[0], ["/fake/herdr", "--session", session, "api", "snapshot"])
self.assertNotIn("HERDR_SOCKET_PATH", run.call_args.kwargs["env"])
self.assertNotIn("HERDR_SESSION", run.call_args.kwargs["env"])
def test_remote_native_and_ssh_route_to_target_named_server(self):
for transport, path in (("native", "/home/user/.herdr/default"),
("ssh", "C:\\Users\\me\\.herdr\\default")):
listing = {"sessions": [{"name": "named", "running": True, "session_dir": path}]}
with self.subTest(transport=transport), patch("herdr_hub.control.subprocess.run", side_effect=[completed(listing), completed(snapshot())]) as run:
control = HerdrControl(Endpoint.remote("ssh://user@host:2222", "named", "server", transport), "/unused", "fake-ssh")
control.source()
command = run.call_args.args[0]
self.assertEqual(command[0], "fake-ssh")
self.assertIn("2222", command)
self.assertIn("host", command)
script = command[-1]
if transport == "ssh":
script = base64.b64decode(script.split()[-1]).decode("utf-16le")
self.assertIn("--session named api snapshot", script)
self.assertIn("HERDR_SOCKET_PATH", script)
def test_windows_missing_path_uses_read_only_fallback_before_mutation(self):
missing = subprocess.CompletedProcess([], 1, b"", b"'herdr' is not recognized")
listing = {"sessions": [{"name": "named", "running": True,
"session_dir": "C:\\Users\\me\\sessions"}]}
with patch("herdr_hub.control.subprocess.run", side_effect=[missing, completed(listing), completed(snapshot())]) as run:
HerdrControl(Endpoint.remote("host", "named", remote_transport="ssh"), "unused").source()
script = base64.b64decode(run.call_args_list[1].args[0][-1].split()[-1]).decode("utf-16le")
self.assertIn("session list --json", script)
self.assertIn(".local", script)
self.assertEqual(len(run.call_args_list), 3)
def test_success_creates_unfocused_tab_starts_then_focuses(self):
for kind, readiness in (("codex", {"interactive_ready": True}),
("claude", {})):
control = HerdrControl(Endpoint.local(), "fake")
control.call = Mock(side_effect=[
{"tab": {"tab_id": "t2"}, "root_pane": {"pane_id": "w1:p2", "workspace_id": "w1", "tab_id": "t2"}},
{"agent": {"pane_id": "w1:p2", "agent": kind, **readiness}},
{},
])
source = parse_snapshot(snapshot(kind))
self.assertEqual(control.fork(source), "t2")
calls = [call.args[0] for call in control.call.call_args_list]
self.assertEqual(calls[0], ["tab", "create", "--workspace", "w1", "--cwd", source.cwd, "--no-focus"])
self.assertEqual(calls[1][3:], ["--kind", kind, "--pane", "w1:p2", "--", *source.arguments])
self.assertRegex(calls[1][2], rf"^{kind}-fork-[a-f0-9]+$")
self.assertEqual(calls[2], ["tab", "focus", "t2"])
def test_failure_never_closes_original_or_created_tab(self):
for failure in (ControlError("agent_not_ready: blocked"), ControlError("timeout"),
{"agent": {"interactive_ready": False}}, {"bad": "response"}):
with self.subTest(failure=failure):
control = HerdrControl(Endpoint.local(), "fake")
control.call = Mock(side_effect=[
{"tab": {"tab_id": "t2"}, "root_pane": {"pane_id": "w1:p2", "workspace_id": "w1", "tab_id": "t2"}}, failure])
with self.assertRaisesRegex(ControlError, "新增 Tab t2 未自动关闭"):
control.fork(parse_snapshot(snapshot()))
self.assertEqual(len(control.call.call_args_list), 2)
self.assertFalse(any("close" in c.args[0] or "focus" in c.args[0] for c in control.call.call_args_list))
def test_wrong_created_pane_never_starts_in_original_tab(self):
for tab_id, pane_id, workspace in (("t1", "w1:p2", "w1"),
("t2", "w1:p1", "w1"),
("t2", "w2:p1", "w2")):
control = HerdrControl(Endpoint.local(), "fake")
control.call = Mock(return_value={"tab": {"tab_id": tab_id},
"root_pane": {"pane_id": pane_id, "tab_id": tab_id, "workspace_id": workspace}})
with self.assertRaises(ControlError):
control.fork(parse_snapshot(snapshot()))
control.call.assert_called_once()
def test_remote_mutation_failure_is_never_replayed(self):
control = HerdrControl(Endpoint.remote("host", "named"), "unused")
control.platform = "posix"
with patch("herdr_hub.control.subprocess.run", return_value=completed({"error": "failed"}, 127)) as run:
with self.assertRaises(ControlError):
control.call(["tab", "create", "--no-focus"])
run.assert_called_once()
def test_ambiguous_create_failure_is_not_replayed_or_cleaned_up(self):
control = HerdrControl(Endpoint.local(), "fake")
control.call = Mock(side_effect=ControlError("timeout"))
with self.assertRaisesRegex(ControlError, "原 Tab 保留"):
control.fork(parse_snapshot(snapshot()))
control.call.assert_called_once()
def test_timeout_and_invalid_json_are_diagnostics(self):
for result in (subprocess.TimeoutExpired(["fake"], 10), subprocess.CompletedProcess([], 0, b"not json", b"")):
with patch("herdr_hub.control.subprocess.run", side_effect=[result]):
with self.assertRaises(ControlError):
HerdrControl(Endpoint.local(), "fake").source()
def test_read_only_request_can_be_cancelled_quickly(self):
control = HerdrControl(Endpoint.local(), "fake")
cancelled = threading.Event()
timer = threading.Timer(0.05, cancelled.set)
timer.start()
started = time.monotonic()
try:
with self.assertRaises(ControlCancelled):
control._execute(["sleep", "5"], 5, cancelled) # type: ignore[attr-defined]
finally:
timer.cancel()
self.assertLess(time.monotonic() - started, 1)
class RemoteCommandTests(unittest.TestCase):
def test_posix_quoting_executes_exact_argv_with_path_fallback(self):
# Shell injection is checked by running only a temporary fake executable.
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
executable = root / ".local/bin/herdr"
executable.parent.mkdir(parents=True)
executable.write_text("#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps(sys.argv[1:]))\n")
executable.chmod(0o755)
arguments = ["--session", "named", "tab", "create", "--cwd", "a b'\";$HOME`id`$(id)\nnext"]
command = remote_control_command("ssh", RemoteEntry("host"), arguments, platform="posix")
result = subprocess.run(["sh", "-c", command[-1]], env={**os.environ, "HOME": directory}, capture_output=True, text=True, check=True)
self.assertEqual(json.loads(result.stdout), arguments)
def test_windows_encoded_script_keeps_untrusted_text_out_of_shell(self):
value = "C:\\a b\\quote'\"; $(whoami) & %PATH%"
command = remote_control_command("ssh", RemoteEntry("host", remote_transport="ssh"), ["tab", "create", "--cwd", value], platform="windows")
self.assertNotIn(value, command[-1])
script = base64.b64decode(command[-1].split()[-1]).decode("utf-16le")
self.assertIn("UseShellExecute=$false", script)
self.assertIn(subprocess.list2cmdline(["tab", "create", "--cwd", value]).replace("'", "''"), script)
class ImmediateFuture:
def __init__(self, fn, *args):
try:
self.value, self.error = fn(*args), None
except Exception as error:
self.value, self.error = None, error
def done(self):
return True
def result(self):
if self.error:
raise self.error
return self.value
class ImmediateExecutor:
def __init__(self, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
pass
def submit(self, fn, *args):
return ImmediateFuture(fn, *args)
class MenuTests(unittest.TestCase):
def setUp(self):
self.control = Mock(endpoint=Endpoint.local())
self.control.source.return_value = parse_snapshot(snapshot())
self.control.fork.return_value = "t2"
self.screen = Mock()
self.screen.getmaxyx.return_value = (24, 90)
def run_menu(self, keys):
self.screen.getch.side_effect = keys
with patch("herdr_hub.control.ThreadPoolExecutor", ImmediateExecutor), patch("curses.set_escdelay"), patch("curses.curs_set"), patch("curses.mousemask"):
return ControlMenu(self.control).run(self.screen)
def test_enter_executes_and_esc_does_not(self):
self.assertEqual(self.run_menu([27]), 0)
self.control.fork.assert_not_called()
self.assertEqual(self.run_menu([10]), 0)
self.control.fork.assert_called_once()
def test_click_executes_action(self):
with patch("curses.getmouse", return_value=(0, 3, 3, 0, curses.BUTTON1_PRESSED)):
self.run_menu([curses.KEY_MOUSE])
self.control.fork.assert_called_once()
def test_error_is_visible_and_enter_cannot_repeat_failed_fork(self):
self.control.fork.side_effect = ControlError("blocked: inspect t2")
self.run_menu([10, 10, 27])
self.control.fork.assert_called_once()
self.assertTrue(any("blocked: inspect t2" in str(call) for call in self.screen.addstr.call_args_list))
def test_snapshot_error_does_not_execute(self):
self.control.source.side_effect = ControlError("没有 Agent")
self.run_menu([10, 27])
self.control.fork.assert_not_called()
class CliControlTests(unittest.TestCase):
def test_active_window_metadata_selects_control_endpoint(self):
args = build_parser().parse_args(["control"])
hub = Mock(ssh_bin="fake-ssh")
endpoint = Endpoint.remote("host", "named", "server", "ssh")
hub.managed_windows.return_value = [
ManagedWindow("@17", endpoint.key, "Remote", False, True, endpoint),
ManagedWindow(
"@18", "local:default", "Other", False, False, Endpoint.local()
),
]
with patch("herdr_hub.cli.make_hub", return_value=hub), patch("herdr_hub.cli.HerdrControl") as controller, patch("herdr_hub.cli.curses.wrapper", return_value=0):
self.assertEqual(command_control(args), 0)
controller.assert_called_once_with(endpoint, "herdr", "fake-ssh")
def test_explicit_window_remains_available_for_cli_use(self):
args = build_parser().parse_args(["control", "--window", "@17"])
hub = Mock(ssh_bin="fake-ssh")
endpoint = Endpoint.local()
hub.managed_windows.return_value = [
ManagedWindow("@17", endpoint.key, "Local", False, False, endpoint)
]
with patch("herdr_hub.cli.make_hub", return_value=hub), patch(
"herdr_hub.cli.HerdrControl"
) as controller, patch("herdr_hub.cli.curses.wrapper", return_value=0):
self.assertEqual(command_control(args), 0)
controller.assert_called_once()
def test_unmanaged_window_rejected(self):
hub = Mock()
hub.managed_windows.return_value = []
with patch("herdr_hub.cli.make_hub", return_value=hub), self.assertRaises(HubError):
command_control(argparse.Namespace(window="@404"))
+15
View File
@@ -59,6 +59,21 @@ class TmuxIntegrationTests(unittest.TestCase):
pass
self.socket_path.unlink(missing_ok=True)
def test_control_uses_existing_prefix_and_active_window(self) -> None:
self.hub.ensure_server()
binding = self.hub.run(["list-keys", "-T", "prefix", "h"]).stdout
self.assertIn("display-popup", binding)
self.assertIn("control", binding)
self.assertNotIn("--window", binding)
self.assertNotIn("#{window_id}", binding)
old_binding = self.hub.run(
["list-keys", "-T", "root", "C-g"], check=False
)
self.assertNotEqual(old_binding.returncode, 0)
self.assertEqual(self.hub.run(["show-options", "-gv", "prefix"]).stdout.strip(), "C-a")
self.assertEqual(self.hub.run(["show-options", "-gv", "prefix2"]).stdout.strip(), "None")
self.assertIn("choose", self.hub.run(["list-keys", "-T", "prefix", "g"]).stdout)
def test_configures_isolated_server_and_adds_without_duplicates(self) -> None:
self.assertTrue(self.hub.ensure_server())
endpoints = [Endpoint.local(), Endpoint.remote("orangepi")]