Add Herdr update and live handoff menu actions

The Ctrl+A h command menu gains two host-scoped actions: a plain
`herdr update` that streams updater output into the popup, and an
experimental update-and-hot-restart flow that verifies a live server
handoff and reconnects the originating Hub tab once the popup closes.

Remote updates use the remote host's own proxy settings. A standalone
helper reads literal proxy assignments from shell profiles and running
Clash/Mihomo listeners without executing startup files; Windows reads
process, user and machine proxy variables through PowerShell.
This commit is contained in:
2026-10-03 02:51:44 +00:00
parent a5e8e32f1e
commit 6f933e1c80
10 changed files with 1130 additions and 38 deletions
+244 -35
View File
@@ -1,4 +1,4 @@
"""Herdr command menu and non-destructive conversation forks."""
"""Herdr command menu, updates, and conversation forks."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
@@ -6,7 +6,11 @@ import curses
from dataclasses import dataclass
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
import threading
import time
from typing import TYPE_CHECKING, Callable, Sequence
@@ -26,6 +30,7 @@ if TYPE_CHECKING:
CONTROL_KEY = "h"
CONTROL_SHORTCUT = "C-a h"
ROUTING_ENV = (
"HERDR_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",
@@ -40,6 +45,16 @@ class ControlCancelled(RuntimeError):
"""A read-only control request cancelled while closing the popup."""
def update_failure(header: str, output: str) -> str:
"""Put the final diagnostic ahead of setup chatter in small popups."""
lines = [line.strip() for line in output.splitlines() if line.strip()]
diagnostic = next((line for line in reversed(lines) if line.startswith("curl:")), "")
if not diagnostic:
diagnostic = next((line for line in reversed(lines)
if not line.startswith("没有自动执行普通重启")), "无错误输出")
return f"{header}\n{diagnostic}\n\n完整命令输出(可翻页):\n{output}"
def object_field(data: dict, name: str) -> dict:
value = data.get(name)
if not isinstance(value, dict):
@@ -99,7 +114,12 @@ def parse_snapshot(payload: object) -> ForkSource:
raise ControlError("当前 pane 没有运行 Agent")
if kind not in ("codex", "claude"):
raise ControlError(f"暂不支持 Fork {kind};支持 Codex / Claude")
session = object_field(pane, "agent_session")
session = pane.get("agent_session")
if not isinstance(session, dict):
raise ControlError(
f"当前 {kind.capitalize()} 尚未向 Herdr 上报会话 ID,暂时无法 Fork。\n"
f"在目标主机检查 herdr integration status;确认集成已加载后按 Ctrl+R 重读。"
)
if session.get("kind") != "id" or session.get("agent") != kind:
raise ControlError("当前 Agent 尚未报告可 Fork 的 conversation ID")
session_id = string_field(session, "value")
@@ -135,8 +155,53 @@ class HerdrControl:
command: Sequence[str],
timeout: float,
cancel_event: threading.Event | None = None,
progress: Callable[[str], None] | None = None,
) -> subprocess.CompletedProcess:
environment = {k: v for k, v in os.environ.items() if k not in ROUTING_ENV}
if progress is not None:
# A file avoids pipe deadlocks and lets us show output before exit,
# including progress indicators that do not end in a newline.
try:
with tempfile.TemporaryFile() as output:
process = subprocess.Popen(command, stdout=output, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, env=environment)
deadline = time.monotonic() + timeout
started = time.monotonic()
last_output = started
last_notice = started
position = 0
captured = b""
try:
while True:
finished = process.poll() is not None
chunk = os.pread(output.fileno(), 65536, position)
if chunk:
position += len(chunk)
captured = (captured + chunk)[-65536:]
progress(decode_remote_output(captured).replace("\r", "\n"))
last_output = time.monotonic()
now = time.monotonic()
if not finished and now - last_notice >= 1:
progress(
decode_remote_output(captured).replace("\r", "\n")
+ f"\n已执行 {int(now - started)} 秒 · {int(now - last_output)} 秒无新输出"
)
last_notice = now
if finished and not chunk:
return subprocess.CompletedProcess(command, process.returncode, captured, b"")
if time.monotonic() >= deadline:
raise ControlError(
f"命令超时({timeout:g}s);操作可能已完成,请检查 Herdr\n"
+ decode_remote_output(captured)
)
if not chunk:
time.sleep(0.1)
finally:
if process.poll() is None:
process.kill()
process.wait()
except OSError as error:
raise ControlError(f"无法执行 Herdr 命令: {error}") from error
if cancel_event is not None and cancel_event.is_set():
raise ControlCancelled()
if cancel_event is not None:
@@ -231,15 +296,7 @@ class HerdrControl:
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)
result = self._execute(self._command(argv, cancel_event), 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())
@@ -248,6 +305,85 @@ class HerdrControl:
except json.JSONDecodeError as error:
raise ControlError(f"Herdr 返回无效 JSON: {error}") from error
def _command(
self, argv: Sequence[str], cancel_event: threading.Event | None = None,
) -> list[str]:
if self.endpoint.kind == "remote":
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
self.endpoint.remote_transport)
return remote_control_command(self.ssh_bin, entry, argv,
platform=self._remote_platform(entry, cancel_event),
unset_environment=ROUTING_ENV)
return [self.herdr_bin, *argv]
def update(self, progress: Callable[[str], None] | None = None) -> str:
"""Run the host updater once; its output is text, not control API JSON."""
if progress:
progress("正在连接更新目标…")
command = self._command(["update"])
if self.endpoint.kind == "remote":
command = self._update_proxy(command, progress)
result = self._execute(command, timeout=300, progress=progress)
stdout, stderr = decode_remote_output(result.stdout), decode_remote_output(result.stderr)
output = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
if result.returncode != 0:
raise ControlError(update_failure(f"更新失败(退出码 {result.returncode})", output))
return "更新命令已完成。重新连接可使用更新后的客户端。\n" + output
def _update_proxy(
self, command: list[str], progress: Callable[[str], None] | None,
) -> list[str]:
"""Load proxy settings on the remote host, never from the Hub host."""
if self.platform == "windows":
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
self.endpoint.remote_transport)
return remote_control_command(
self.ssh_bin, entry, ["update"], platform="windows",
unset_environment=ROUTING_ENV, load_proxy_environment=True,
)
if self.platform != "posix":
return command
if progress:
progress("正在检测远端自身的代理配置…")
helper = Path(__file__).with_name("proxy_env.py").read_text(encoding="utf-8")
result = list(command)
result[-1] = (
"if command -v python3 >/dev/null 2>&1; then exec python3 -u -c "
+ shlex.quote(helper) + " " + shlex.quote(command[-1])
+ "; else echo '远端没有 python3,沿用远端现有代理环境及 curl 配置'; "
+ command[-1] + "; fi"
)
return result
def update_and_restart(self, progress: Callable[[str], None]) -> str:
"""Update and verify a live server handoff on the selected POSIX session."""
helper = str(Path(__file__).with_name("restart.py"))
if self.endpoint.kind == "local":
command = [sys.executable, helper, self.herdr_bin, self.endpoint.session]
else:
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
self.endpoint.remote_transport)
progress("正在检查远端热切换支持…")
if self._remote_platform(entry) != "posix":
raise ControlError("Windows 不支持热切换;请使用“更新 Herdr”,再安排普通重启。")
# Reuse the SSH destination/options, but run the standalone helper
# on the host that owns the Unix socket. No socket crosses SSH.
command = remote_control_command(self.ssh_bin, entry, [], platform="posix")
script = Path(helper).read_text(encoding="utf-8")
command[-1] = (
"".join(f"unset {key}; " for key in ROUTING_ENV)
+ 'if [ -x "$HOME/.local/bin/herdr" ]; then hub_herdr="$HOME/.local/bin/herdr"; '
'else hub_herdr=$(command -v herdr) || exit 127; fi; '
+ "exec python3 -u -c " + shlex.quote(script)
+ ' "$hub_herdr" ' + shlex.quote(self.endpoint.session)
)
command = self._update_proxy(command, progress)
result = self._execute(command, timeout=400, progress=progress)
output = decode_remote_output(result.stdout)
if result.returncode != 0:
raise ControlError(update_failure("更新/热切换失败;未自动执行普通重启。", output))
return output
def source(
self,
cancel_event: threading.Event | None = None,
@@ -319,10 +455,13 @@ class ControlMenu:
"Fork 当前会话到新 Tab",
"保留当前 Tab,在同一 Workspace 和工作目录继续",
),
("update", "更新 Herdr", "更新当前主机的 Herdr(使用已配置的更新渠道)"),
("update-restart", "更新并热重启 Herdr", "实验性热切换,保留进程;成功后自动重连当前 Tab"),
)
def __init__(self, control: HerdrControl) -> None:
self.control = control
self.reconnect_requested = False
def run(self, screen) -> int: # type: ignore[no-untyped-def]
import queue
@@ -336,8 +475,13 @@ class ControlMenu:
except (AttributeError, curses.error):
pass
source: ForkSource | None = None
source_error = ""
status = ""
selected = 0
update_attempted = False
pending_update: str | None = None
status_offset = 0
target = "本机" if self.control.endpoint.kind == "local" else self.control.endpoint.target
cancel_loading = threading.Event()
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(self.control.source, cancel_loading)
@@ -346,22 +490,46 @@ class ControlMenu:
while not messages.empty():
status = messages.get()
if future is not None and future.done():
# The worker is finished: discard queued progress before
# publishing its final result so stale output cannot replace it.
while not messages.empty():
messages.get()
status_offset = 0
try:
value = future.result()
if phase == "load":
source = value
source_error = ""
status = ""
elif phase == "update":
status = value
elif phase == "update-restart":
self.reconnect_requested = True
return 0
else:
return 0
except ControlCancelled:
return 0
if not pending_update:
return 0
except Exception as error:
status = str(error)
if phase == "load":
source_error = status
if phase == "fork":
source = None # Avoid accidental duplicate forks after ambiguous failure.
source_error = status
future = None
if pending_update and future is None:
phase = pending_update
pending_update = None
update_attempted = True
method = self.control.update if phase == "update" else self.control.update_and_restart
future = executor.submit(method, messages.put)
status = f"正在更新 {target} 的 Herdr…"
status_offset = 0
screen.erase()
height, width = screen.getmaxyx()
compact = height < 16
if source is not None:
context = (
f"当前:{source.kind.capitalize()} · "
@@ -371,11 +539,18 @@ class ControlMenu:
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
if not compact:
self._add(screen, 1, 2, context, curses.A_BOLD)
heading = context if compact and selected == 0 else f"更新目标:{target}"
self._add(screen, 0 if compact else 2, 2, heading, curses.A_BOLD)
action_top = 1 if compact else 3
action_stride = 1 if compact else 2
for index, (action, label, detail) in enumerate(self.ACTIONS):
y = action_top + index * action_stride
enabled = (
source is not None and future is None if action == "fork"
else not update_attempted and (future is None or phase == "load")
)
attribute = (
curses.A_REVERSE
if enabled and index == selected
@@ -383,21 +558,27 @@ class ControlMenu:
if enabled
else curses.A_DIM
)
if action == "fork" and source is None:
label += "(读取中)" if phase == "load" and future is not None else "(不可用)"
self._add(screen, y, 2, f" {label} ", attribute)
self._add(screen, y + 1, 4, detail, curses.A_DIM)
if not compact:
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 []
):
status_top = action_top + len(self.ACTIONS) * action_stride + (0 if compact else 1)
status_lines = display_chunks(status, max(1, width - 5)) if status else []
visible_lines = max(1, height - status_top - 1)
status_offset = min(status_offset, max(0, len(status_lines) - visible_lines))
if future is not None and phase in ("update", "update-restart"):
status_offset = max(0, len(status_lines) - visible_lines)
for offset, line in enumerate(status_lines[status_offset:status_offset + visible_lines]):
self._add(screen, status_top + offset, 2, line, curses.A_DIM)
footer = (
"操作执行中,请等待结果"
if future is not None and phase == "fork"
else "Enter 执行 · Esc 关闭"
if future is not None and phase != "load"
else "Enter 执行 · Ctrl+R 重读 · PgUp/PgDn 结果 · Esc 关闭"
)
self._add(screen, height - 2, 2, footer, curses.A_DIM)
self._add(screen, height - 1, 2, footer, curses.A_DIM)
screen.refresh()
key = screen.getch()
if key == 27:
@@ -406,7 +587,19 @@ class ControlMenu:
return 0
if future is None:
return 0
status = "操作执行中;完成后可关闭(避免误以为已取消 Fork)。"
status = "操作执行中,请等待结果;关闭菜单不会取消已发送的操作。"
elif key == 18 and future is None:
source = None
source_error = ""
cancel_loading = threading.Event()
future = executor.submit(self.control.source, cancel_loading)
phase = "load"
status = "正在重新读取当前页面…"
status_offset = 0
elif key in (curses.KEY_PPAGE, curses.KEY_NPAGE):
status_offset = max(0, status_offset + (
visible_lines if key == curses.KEY_NPAGE else -visible_lines
))
elif key in (curses.KEY_UP, curses.KEY_DOWN):
selected = (selected + (1 if key == curses.KEY_DOWN else -1)) % len(self.ACTIONS)
else:
@@ -414,14 +607,10 @@ class ControlMenu:
if key == curses.KEY_MOUSE:
try:
_, x, y, _, state = curses.getmouse()
action_index = (y - action_top) // 2
action_index = (y - action_top) // action_stride
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
& (
@@ -434,10 +623,30 @@ class ControlMenu:
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…"
if execute:
action = self.ACTIONS[selected][0]
if action in ("update", "update-restart") and not update_attempted and phase == "load" and future is not None:
pending_update = action
cancel_loading.set()
status = "正在准备更新…"
elif action == "fork" and source is not None and future is None:
phase = "fork"
future = executor.submit(self.control.fork, source, messages.put)
status = "正在 Fork…"
status_offset = 0
elif action == "fork":
if future is None:
status = source_error or "尚未读取 Fork 会话信息,请按 Ctrl+R 重读。"
status_offset = 0
elif phase == "load":
status = "正在读取 Fork 会话信息,请稍候再执行。"
elif action in ("update", "update-restart") and not update_attempted and future is None:
phase = action
update_attempted = True
method = self.control.update if action == "update" else self.control.update_and_restart
future = executor.submit(method, messages.put)
status = f"正在更新 {target} 的 Herdr,请等待…"
status_offset = 0
@staticmethod
def _add(