/** * pi-hot-restart * * 提供 `/pi-hot-restart` 命令:批量重启当前 herdr 里的所有 pi 窗口, * 每个窗口重启后恢复到原来的 session。 * * 行为约定: * - 跳过 agent_status 为 "working" 的窗口,以及当前发起命令的窗口自身 * (按 `HERDR_PANE_ID` 排除;敲命令时该窗口是 idle,不能只靠状态排除, * 否则会把自己的 pi 进程也 SIGTERM 掉,循环当场中断)。 * - 重启前弹确认框,列出将重启的窗口与数量。 * - 通过向 pi 前台进程组发 SIGTERM 触发优雅退出(session 落盘), * 退出后用 `herdr pane run` 以 `pi --session ` 重新拉起。 * * 依赖: * - 运行在 herdr 集成环境中(HERDR_ENV=1 等),通过 PATH 调用 `herdr` CLI。 * - `herdr agent list` / `herdr pane process-info` / `herdr pane run` 子命令。 */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { execFile } from "node:child_process"; const HERDR = "herdr"; interface AgentSession { kind: string; value: string; } interface PiAgent { agent: string; agent_status: string; cwd: string; pane_id: string; terminal_title?: string; agent_session?: AgentSession; } interface ForegroundProcess { argv0?: string; } interface ProcInfo { foreground_process_group_id?: number; foreground_processes?: ForegroundProcess[]; } interface HerdrListResult { result?: { agents?: PiAgent[] }; } interface HerdrProcInfoResult { result?: { process_info?: ProcInfo }; } /** 执行 herdr CLI,无论退出码如何都返回 stdout/stderr/code,方便解析错误 JSON。 */ function runHerdr(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> { return new Promise((resolve) => { execFile( HERDR, args, { env: process.env, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => { resolve({ stdout: stdout ?? "", stderr: stderr ?? "", code: error ? Number((error as NodeJS.ErrnoException).code ?? 1) : 0, }); }, ); }); } async function herdrJson(args: string[]): Promise { const { stdout, stderr, code } = await runHerdr(args); let parsed: unknown; try { parsed = JSON.parse(stdout); } catch { throw new Error(`herdr ${args.join(" ")} 输出不是 JSON (code=${code}): ${stderr || stdout}`); } if (code !== 0 && typeof parsed === "object" && parsed !== null && "error" in parsed) { throw new Error(`herdr ${args.join(" ")} 失败: ${String((parsed as { error: unknown }).error)}`); } return parsed as T; } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } function shellQuote(s: string): string { return `'${s.replace(/'/g, `'\\''`)}'`; } /** 取当前 herdr 中所有已识别的 pi 窗口。 */ async function listPiAgents(): Promise { const data = await herdrJson(["agent", "list"]); return (data?.result?.agents ?? []).filter((a) => a.agent === "pi"); } /** 该 pane 的前台进程组中是否还存在 pi 进程。 */ async function hasPiForeground(paneId: string): Promise { try { const data = await herdrJson(["pane", "process-info", "--pane", paneId]); const procs = data?.result?.process_info?.foreground_processes ?? []; return procs.some((p) => p.argv0 === "pi"); } catch { // 拿不到进程信息时按「已退出」处理,避免卡死等待。 return false; } } /** 向前台进程组发 SIGTERM,只杀 pi + 同组子进程,保留 shell。 */ async function terminatePane(paneId: string): Promise { try { const data = await herdrJson(["pane", "process-info", "--pane", paneId]); const pgid = data?.result?.process_info?.foreground_process_group_id; if (!pgid) return false; process.kill(-pgid, "SIGTERM"); return true; } catch { return false; } } /** 轮询等待 pi 真正退出;超时则抛错(避免在旧 pi 仍活着时重启)。 */ async function waitExit(paneId: string, timeoutMs = 15000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!(await hasPiForeground(paneId))) return; await sleep(300); } throw new Error("等待 pi 退出超时"); } /** 在 pane 里以原 session 重新拉起 pi。 */ async function relaunch(agent: PiAgent): Promise { const session = agent.agent_session?.value; if (!session) throw new Error(`pane ${agent.pane_id} 无 session 信息,无法恢复`); const cmd = `cd ${shellQuote(agent.cwd)} && pi --session ${shellQuote(session)}`; const { code, stderr } = await runHerdr(["pane", "run", agent.pane_id, cmd]); if (code !== 0) throw new Error(stderr || `herdr pane run 退出码 ${code}`); } export default function (pi: ExtensionAPI) { pi.registerCommand("pi-hot-restart", { description: "批量重启当前 herdr 里的所有 pi 窗口(跳过 working 与当前窗口),并恢复到原 session", handler: async (_args, ctx) => { let agents: PiAgent[]; try { agents = await listPiAgents(); } catch (e) { ctx.ui.notify(`获取 pi 窗口失败: ${(e as Error).message}`, "error"); return; } if (agents.length === 0) { ctx.ui.notify("没有发现 pi 窗口", "info"); return; } const self = process.env.HERDR_PANE_ID; const targets = agents.filter((a) => a.agent_status !== "working" && a.pane_id !== self); const skipped = agents.filter((a) => a.agent_status === "working" || a.pane_id === self); if (targets.length === 0) { ctx.ui.notify(`没有可重启的 pi 窗口(共 ${agents.length} 个,其余为 working 或当前窗口)`, "info"); return; } const list = targets .map((a) => ` - ${a.terminal_title ?? a.pane_id} (${a.cwd})`) .join("\n"); const skipNote = skipped.length ? `\n\n将跳过 ${skipped.length} 个窗口(working 或当前窗口)` : ""; const ok = await ctx.ui.confirm( "pi-hot-restart", `将重启 ${targets.length} 个 pi 窗口:\n${list}${skipNote}`, ); if (!ok) return; let done = 0; const failures: string[] = []; for (const agent of targets) { try { const killed = await terminatePane(agent.pane_id); if (killed) { await waitExit(agent.pane_id); } await sleep(300); // 给 shell 一点时间回到 prompt await relaunch(agent); done++; } catch (e) { failures.push(`${agent.terminal_title ?? agent.pane_id}: ${(e as Error).message}`); } } if (failures.length) { ctx.ui.notify(`重启完成 ${done}/${targets.length} 个,失败 ${failures.length} 个`, "warning"); ctx.ui.notify(failures.join("\n"), "error"); } else { ctx.ui.notify(`已重启 ${done} 个 pi 窗口`, "info"); } }, }); }