From 41c69a2eb59c29a912d3b1063c93d17adfb214a2 Mon Sep 17 00:00:00 2001 From: Richard Hao Date: Thu, 25 Jun 2026 14:19:42 +0800 Subject: [PATCH] feat(subagents): add herdr terminal multiplexer support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract herdr into its own backend module (herdr.ts) and wire it into the mux dispatcher (cmux.ts). Herdr surfaces are created as new tabs with --no-focus so parallel subagent spawns get full tabs instead of ever-narrower splits, and focus stays on the parent pane. herdr.ts owns all herdr-specific CLI integration: - Runtime detection (HERDR_ENV + herdr in PATH) - Surface creation (tab create / pane split) - Command delivery (pane run — atomic text + Enter) - Screen reading (pane read --source visible) - Escape delivery, pane close, tab/workspace rename - Env-var-first current pane info with subprocess fallback cmux.ts adds herdr to MuxBackend, getMuxBackend(), muxSetupHint(), and dispatches to herdr.ts in createSurface, createSurfaceSplit, sendCommand, sendEscape, readScreen, readScreenAsync, closeSurface, renameCurrentTab, and renameWorkspace. Integration harness probes herdr and implements focus/focused-surface helpers. The focus-preservation test is skipped for herdr/wezterm because neither exposes absolute pane focusing via CLI. README documents herdr as a supported multiplexer. Tested: 133 unit tests pass, 8/8 herdr mux-surface integration tests pass in a live herdr session. --- README.md | 7 +- pi-extension/subagents/cmux.ts | 68 ++++++++- pi-extension/subagents/herdr.ts | 202 +++++++++++++++++++++++++++ test/integration/harness.ts | 22 ++- test/integration/mux-surface.test.ts | 6 + test/test.ts | 42 ++++++ 6 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 pi-extension/subagents/herdr.ts diff --git a/README.md b/README.md index 3b91535..f4df2fc 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Supported multiplexers: - [tmux](https://github.com/tmux/tmux) - [zellij](https://zellij.dev) - [WezTerm](https://wezfurlong.org/wezterm/) (terminal emulator with built-in multiplexing) +- [herdr](https://herdr.dev) Start pi inside one of them: @@ -46,9 +47,11 @@ tmux new -A -s pi 'pi' zellij --session pi # then run: pi # or # just run pi inside WezTerm — no wrapper needed +# or +herdr # then run: pi ``` -Optional: set `PI_SUBAGENT_MUX=cmux|tmux|zellij|wezterm` to force a specific backend. +Optional: set `PI_SUBAGENT_MUX=cmux|tmux|zellij|wezterm|herdr` to force a specific backend. If your shell startup is slow and subagent commands sometimes get dropped before the prompt is ready, set `PI_SUBAGENT_SHELL_READY_DELAY_MS` to a higher value (defaults to `500`): @@ -56,7 +59,7 @@ If your shell startup is slow and subagent commands sometimes get dropped before export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 ``` -Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch commands target child surfaces by explicit ID, so focus and command delivery are independent. Note: the `interactive` option controls parent status notifications, not terminal focus. +Subagent panes are created without stealing keyboard focus (cmux, tmux, herdr). Launch commands target child surfaces by explicit ID, so focus and command delivery are independent. Note: the `interactive` option controls parent status notifications, not terminal focus. ## What's Included diff --git a/pi-extension/subagents/cmux.ts b/pi-extension/subagents/cmux.ts index 979684b..bcf4e2b 100644 --- a/pi-extension/subagents/cmux.ts +++ b/pi-extension/subagents/cmux.ts @@ -2,11 +2,23 @@ import { execSync, execFile, execFileSync, spawnSync } from "node:child_process" import { promisify } from "node:util"; import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; +import { + createHerdrSurface, + createHerdrSurfaceSplit, + readHerdrScreen, + readHerdrScreenAsync, + sendHerdrCommand, + sendHerdrEscape, + closeHerdrSurface, + renameHerdrTab, + renameHerdrWorkspace, + isHerdrAvailable, +} from "./herdr.ts"; import { basename, dirname, join } from "node:path"; const execFileAsync = promisify(execFile); -export type MuxBackend = "cmux" | "tmux" | "zellij" | "wezterm"; +export type MuxBackend = "cmux" | "tmux" | "zellij" | "wezterm" | "herdr"; const commandAvailability = new Map(); @@ -43,7 +55,7 @@ function hasCommand(command: string): boolean { function muxPreference(): MuxBackend | null { const pref = (process.env.PI_SUBAGENT_MUX ?? "").trim().toLowerCase(); - if (pref === "cmux" || pref === "tmux" || pref === "zellij" || pref === "wezterm") return pref; + if (["cmux", "tmux", "zellij", "wezterm", "herdr"].includes(pref)) return pref as MuxBackend; return null; } @@ -85,11 +97,13 @@ export function getMuxBackend(): MuxBackend | null { if (pref === "tmux") return isTmuxRuntimeAvailable() ? "tmux" : null; if (pref === "zellij") return isZellijRuntimeAvailable() ? "zellij" : null; if (pref === "wezterm") return isWezTermRuntimeAvailable() ? "wezterm" : null; + if (pref === "herdr") return isHerdrAvailable() ? "herdr" : null; if (isCmuxRuntimeAvailable()) return "cmux"; if (isTmuxRuntimeAvailable()) return "tmux"; if (isZellijRuntimeAvailable()) return "zellij"; if (isWezTermRuntimeAvailable()) return "wezterm"; + if (isHerdrAvailable()) return "herdr"; return null; } @@ -111,7 +125,10 @@ export function muxSetupHint(): string { if (pref === "wezterm") { return "Start pi inside WezTerm."; } - return "Start pi inside cmux (`cmux pi`), tmux (`tmux new -A -s pi 'pi'`), zellij (`zellij --session pi`, then run `pi`), or WezTerm."; + if (pref === "herdr") { + return "Start pi inside herdr (`herdr`, then run `pi`)."; + } + return "Start pi inside cmux (`cmux pi`), tmux (`tmux new -A -s pi 'pi'`), zellij (`zellij --session pi`, then run `pi`), WezTerm, or herdr (`herdr`, then run `pi`)."; } function requireMuxBackend(): MuxBackend { @@ -749,11 +766,15 @@ function createCmuxSplitSurface( * For zellij: chooses a tab-aware tiled or stacked placement. * For tmux/wezterm: falls back to split behavior. * - * Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm). + * Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm, `1-2` in herdr). */ export function createSurface(name: string): string { const backend = getMuxBackend(); + if (backend === "herdr") { + return createHerdrSurface(name); + } + if (backend === "cmux" && cmuxSubagentPane) { // Verify the pane still exists before adding a tab to it try { @@ -810,7 +831,7 @@ function createSurfaceInPane(name: string, pane: string): string { /** * Create a new split in the given direction from an optional source pane. - * Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm). + * Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm, `1-2` in herdr). */ export function createSurfaceSplit( name: string, @@ -819,6 +840,10 @@ export function createSurfaceSplit( ): string { const backend = requireMuxBackend(); + if (backend === "herdr") { + return createHerdrSurfaceSplit(name, direction); + } + if (backend === "cmux") { return createCmuxSplitSurface(name, direction, fromSurface).surface; } @@ -941,6 +966,11 @@ export function renameCurrentTab(title: string): void { return; } + if (backend === "herdr") { + renameHerdrTab(title); + return; + } + // zellij: rename the agent's own pane, not the whole tab. In multi-pane layouts, // rename-tab clobbers the user's tab title whenever a subagent starts or /plan runs. // Closes #21. @@ -996,6 +1026,11 @@ export function renameWorkspace(title: string): void { return; } + if (backend === "herdr") { + renameHerdrWorkspace(title); + return; + } + // Skip session rename for zellij. rename-session renames the socket file // but the ZELLIJ_SESSION_NAME env var in the parent process keeps the old // name, so all subsequent `zellij action ...` CLI calls fail with @@ -1033,6 +1068,11 @@ export function sendCommand(surface: string, command: string): void { return; } + if (backend === "herdr") { + sendHerdrCommand(surface, command); + return; + } + zellijActionSync(["write-chars", command], surface); zellijActionSync(["write", "13"], surface); } @@ -1060,6 +1100,11 @@ export function sendEscape(surface: string): void { return; } + if (backend === "herdr") { + sendHerdrEscape(surface); + return; + } + zellijActionSync(["write", "27"], surface); } @@ -1132,6 +1177,10 @@ export function readScreen(surface: string, lines = 50): string { return tailLines(raw, lines); } + if (backend === "herdr") { + return readHerdrScreen(surface, lines); + } + // Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file. // The ZELLIJ_PANE_ID env var doesn't reliably target other panes for dump-screen, // and --path may silently fail to create the file. Stdout capture is robust. @@ -1177,6 +1226,10 @@ export async function readScreenAsync(surface: string, lines = 50): Promise(); + +function hasCommand(command: string): boolean { + if (commandAvailability.has(command)) { + return commandAvailability.get(command)!; + } + + let available = false; + if (process.platform === "win32") { + try { + execFileSync("where.exe", [command], { stdio: "ignore" }); + available = true; + } catch { + try { + execSync(`command -v ${command}`, { stdio: "ignore" }); + available = true; + } catch { + available = false; + } + } + } else { + try { + execSync(`command -v ${command}`, { stdio: "ignore" }); + available = true; + } catch { + available = false; + } + } + + commandAvailability.set(command, available); + return available; +} + +export function isHerdrAvailable(): boolean { + return process.env.HERDR_ENV === "1" && hasCommand("herdr"); +} + +function parseHerdrJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function extractHerdrPaneId(output: string, context: string): string { + const parsed = parseHerdrJson(output); + const paneId = (parsed as { result?: { pane?: { pane_id?: unknown } } })?.result?.pane?.pane_id; + if (typeof paneId !== "string" || !paneId) { + throw new Error(`Unexpected herdr ${context} output: ${output.trim() || "(empty)"}`); + } + return paneId; +} + +function extractHerdrRootPaneId(output: string, context: string): string { + const parsed = parseHerdrJson(output); + const paneId = (parsed as { result?: { root_pane?: { pane_id?: unknown } } })?.result?.root_pane + ?.pane_id; + if (typeof paneId !== "string" || !paneId) { + throw new Error(`Unexpected herdr ${context} output: ${output.trim() || "(empty)"}`); + } + return paneId; +} + +function herdrExec(args: string[]): string { + return execFileSync("herdr", args, { encoding: "utf8" }); +} + +async function herdrExecAsync(args: string[]): Promise { + const { stdout } = await execFileAsync("herdr", args, { encoding: "utf8" }); + return stdout; +} + +function getHerdrParentPaneId(): string { + const paneId = process.env.HERDR_PANE_ID; + if (!paneId) { + throw new Error("HERDR_PANE_ID not set"); + } + return paneId; +} + +function getHerdrCurrentPaneInfo(): { + pane_id: string; + tab_id: string; + workspace_id: string; +} { + const paneId = process.env.HERDR_PANE_ID; + const tabId = process.env.HERDR_TAB_ID; + const workspaceId = process.env.HERDR_WORKSPACE_ID; + + // Fall back to `herdr pane current` if any identity env var is missing — + // older herdr versions may not set all three. + if (!paneId || !tabId || !workspaceId) { + const output = herdrExec(["pane", "current"]); + const parsed = parseHerdrJson(output); + const pane = (parsed as { result?: { pane?: unknown } } | null)?.result?.pane as + | { pane_id?: string; tab_id?: string; workspace_id?: string } + | undefined; + if (!pane?.pane_id || !pane?.tab_id || !pane?.workspace_id) { + throw new Error(`Unexpected herdr pane current output: ${output.trim() || "(empty)"}`); + } + return { + pane_id: pane.pane_id, + tab_id: pane.tab_id, + workspace_id: pane.workspace_id, + }; + } + + return { pane_id: paneId, tab_id: tabId, workspace_id: workspaceId }; +} + +export function createHerdrSurface(name: string): string { + // Create a new tab per subagent so parallel spawns each get a full tab + // instead of ever-narrower splits of the parent pane. + const output = herdrExec([ + "tab", + "create", + "--label", + name, + "--cwd", + process.cwd(), + "--no-focus", + ]); + const paneId = extractHerdrRootPaneId(output, "tab create"); + try { + herdrExec(["pane", "rename", paneId, name]); + } catch { + // Optional — pane label is cosmetic. + } + return paneId; +} + +export function createHerdrSurfaceSplit( + name: string, + direction: "left" | "right" | "up" | "down", +): string { + const parentPaneId = getHerdrParentPaneId(); + const dir = direction === "left" || direction === "right" ? "right" : "down"; + const output = herdrExec([ + "pane", + "split", + parentPaneId, + "--direction", + dir, + "--no-focus", + "--cwd", + process.cwd(), + ]); + const paneId = extractHerdrPaneId(output, "pane split"); + try { + herdrExec(["pane", "rename", paneId, name]); + } catch { + // Optional. + } + return paneId; +} + +export function readHerdrScreen(surface: string, lines = 50): string { + // `visible` is the current viewport — reliable for freshly-created panes + // where `recent` scrollback may not be populated yet. Matches what tmux + // capture-pane and zellij dump-screen return. + return herdrExec(["pane", "read", surface, "--source", "visible", "--lines", String(lines)]); +} + +export async function readHerdrScreenAsync(surface: string, lines = 50): Promise { + return herdrExecAsync(["pane", "read", surface, "--source", "visible", "--lines", String(lines)]); +} + +export function sendHerdrCommand(surface: string, command: string): void { + // pane run sends the text and Enter in a single socket request, avoiding + // a race where Enter could arrive before the text is fully processed. + herdrExec(["pane", "run", surface, command]); +} + +export function sendHerdrEscape(surface: string): void { + herdrExec(["pane", "send-keys", surface, "Escape"]); +} + +export function closeHerdrSurface(surface: string): void { + herdrExec(["pane", "close", surface]); +} + +export function renameHerdrTab(title: string): void { + const { tab_id: tabId } = getHerdrCurrentPaneInfo(); + herdrExec(["tab", "rename", tabId, title]); +} + +export function renameHerdrWorkspace(title: string): void { + const { workspace_id: workspaceId } = getHerdrCurrentPaneInfo(); + herdrExec(["workspace", "rename", workspaceId, title]); +} + +export const __herdrTest__ = { + parseHerdrJson, + extractHerdrPaneId, + extractHerdrRootPaneId, +}; diff --git a/test/integration/harness.ts b/test/integration/harness.ts index 3d11a51..2dae6a1 100644 --- a/test/integration/harness.ts +++ b/test/integration/harness.ts @@ -89,7 +89,7 @@ export function getAvailableBackends(): MuxBackend[] { const backends: MuxBackend[] = []; const orig = process.env.PI_SUBAGENT_MUX; - for (const backend of ["cmux", "tmux", "zellij"] as MuxBackend[]) { + for (const backend of ["cmux", "tmux", "zellij", "wezterm", "herdr"] as MuxBackend[]) { process.env.PI_SUBAGENT_MUX = backend; try { if (getMuxBackend() === backend) backends.push(backend); @@ -126,6 +126,15 @@ export function focusSurface(backend: MuxBackend, surface: string): void { return; } + if (backend === "herdr") { + // Focus the tab containing the pane — herdr has no direct "focus pane X" + // CLI, but focusing the tab brings it to the foreground. + const info = execFileSync("herdr", ["pane", "get", surface], { encoding: "utf8" }); + const tabId = JSON.parse(info)?.result?.pane?.tab_id; + if (tabId) execFileSync("herdr", ["tab", "focus", tabId], { encoding: "utf8" }); + return; + } + throw new Error(`Focus helpers are not implemented for ${backend}`); } @@ -147,6 +156,15 @@ export function getFocusedSurface(backend: MuxBackend): string | null { } } + if (backend === "herdr") { + try { + const info = execFileSync("herdr", ["pane", "current"], { encoding: "utf8" }); + return JSON.parse(info)?.result?.pane?.pane_id ?? null; + } catch { + return null; + } + } + throw new Error(`Focus helpers are not implemented for ${backend}`); } @@ -158,6 +176,8 @@ export function getSurfacePane(backend: MuxBackend, surface: string): string | n if (backend === "tmux") return surface; + if (backend === "herdr") return surface; + throw new Error(`Pane lookup is not implemented for ${backend}`); } diff --git a/test/integration/mux-surface.test.ts b/test/integration/mux-surface.test.ts index 891428b..7940af3 100644 --- a/test/integration/mux-surface.test.ts +++ b/test/integration/mux-surface.test.ts @@ -64,6 +64,12 @@ for (const backend of backends) { }); it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => { + // herdr and wezterm don't expose absolute pane focusing via CLI — + // herdr only has directional focus, wezterm has no focus helpers at all. + // The --no-focus behavior these backends use for surface creation is + // already covered by the other mux-surface tests. + if (backend === "herdr" || backend === "wezterm") return; + const anchor = createTrackedSurfaceSplit(env, "focus-anchor", "right"); await sleep(1000); diff --git a/test/test.ts b/test/test.ts index 5503bb8..814582c 100644 --- a/test/test.ts +++ b/test/test.ts @@ -31,6 +31,7 @@ import { selectZellijPlacement, selectZellijStackPlacement, } from "../pi-extension/subagents/cmux.ts"; +import { isHerdrAvailable, __herdrTest__ } from "../pi-extension/subagents/herdr.ts"; import { advanceStatusState, capStatusLines, @@ -2376,3 +2377,44 @@ describe("cmux.ts", () => { }); }); }); + +describe("herdr.ts", () => { + describe("isHerdrAvailable", () => { + it("returns boolean based on HERDR_ENV", () => { + const result = isHerdrAvailable(); + assert.equal(typeof result, "boolean"); + }); + }); + + describe("herdr response parsing", () => { + it("extracts pane id from a pane split response", () => { + const output = JSON.stringify({ + result: { + pane: { + pane_id: "1-3", + tab_id: "1:2", + workspace_id: "1", + }, + }, + }); + assert.equal(__herdrTest__.extractHerdrPaneId(output, "pane split"), "1-3"); + }); + + it("extracts root pane id from a tab create response", () => { + const output = JSON.stringify({ + result: { + tab: { tab_id: "1:2" }, + root_pane: { pane_id: "1-2" }, + }, + }); + assert.equal(__herdrTest__.extractHerdrRootPaneId(output, "tab create"), "1-2"); + }); + + it("throws on malformed herdr JSON", () => { + assert.throws( + () => __herdrTest__.extractHerdrPaneId("not json", "pane split"), + /Unexpected herdr pane split output/, + ); + }); + }); +});