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:
+244
-35
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user