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/, + ); + }); + }); +});