Commit 4d7ccceda884
Changed files (8)
dots
pi
agent
extensions
dots/pi/agent/extensions/herdr/herdr.test.ts
@@ -0,0 +1,417 @@
+/**
+ * Tests for the Herdr extension.
+ *
+ * Run with: bun test herdr.test.ts
+ */
+
+import { describe, expect, test } from "bun:test";
+import {
+ buildArgs,
+ buildConfirmation,
+ currentPaneId,
+ errorFromFailure,
+ extractPaneId,
+ extractWorkspaceId,
+ formatAgents,
+ formatCreated,
+ formatPane,
+ formatWorkspaces,
+ isInsideHerdr,
+ isRawOutput,
+ isWriteAction,
+ parseAgents,
+ parseEnvelope,
+ parsePane,
+ parseResponse,
+ parseWorkspaces,
+ statusIcon,
+ truncate,
+} from "./utils";
+
+// Captured from the real CLI (`herdr agent list`).
+const AGENT_LIST = {
+ agents: [
+ {
+ agent: "pi",
+ agent_session: { agent: "pi", kind: "path", source: "herdr:pi", value: "/home/u/.pi/s.jsonl" },
+ agent_status: "working",
+ cwd: "/home/u/src/home",
+ focused: false,
+ foreground_cwd: "/home/u/src/home",
+ pane_id: "w2:p1",
+ tab_id: "w2:t1",
+ terminal_title: "π home",
+ terminal_title_stripped: "π home",
+ workspace_id: "w2",
+ },
+ {
+ agent: "pi",
+ agent_status: "idle",
+ cwd: "/home/u/desktop/org",
+ focused: true,
+ pane_id: "w8:p2",
+ tab_id: "w8:t2",
+ workspace_id: "w8",
+ },
+ ],
+ type: "agent_list",
+};
+
+const WORKSPACE_LIST = {
+ type: "workspace_list",
+ workspaces: [
+ { active_tab_id: "w2:t1", agent_status: "working", focused: false, label: "home", number: 1, pane_count: 3, tab_count: 3, workspace_id: "w2" },
+ { active_tab_id: "wA:t1", agent_status: "idle", focused: true, label: "community", number: 3, pane_count: 1, tab_count: 1, workspace_id: "wA" },
+ ],
+};
+
+// ============================================================================
+// Environment
+// ============================================================================
+
+describe("environment", () => {
+ test("isInsideHerdr only when HERDR_ENV=1", () => {
+ expect(isInsideHerdr({ HERDR_ENV: "1" } as any)).toBe(true);
+ expect(isInsideHerdr({ HERDR_ENV: "0" } as any)).toBe(false);
+ expect(isInsideHerdr({} as any)).toBe(false);
+ });
+
+ test("currentPaneId reads HERDR_PANE_ID", () => {
+ expect(currentPaneId({ HERDR_PANE_ID: "w2:p1" } as any)).toBe("w2:p1");
+ expect(currentPaneId({} as any)).toBeUndefined();
+ });
+});
+
+// ============================================================================
+// Envelope parsing
+// ============================================================================
+
+describe("parseEnvelope", () => {
+ test("unwraps result", () => {
+ const res = parseEnvelope(JSON.stringify({ id: "cli:agent:list", result: AGENT_LIST }));
+ expect(res.ok).toBe(true);
+ if (res.ok) expect(res.result.type).toBe("agent_list");
+ });
+
+ test("surfaces error message", () => {
+ const res = parseEnvelope(JSON.stringify({ id: "x", error: { code: 3, message: "pane not found" } }));
+ expect(res).toEqual({ ok: false, error: "pane not found" });
+ });
+
+ test("handles empty and non-JSON output", () => {
+ expect(parseEnvelope(" ").ok).toBe(false);
+ expect(parseEnvelope("herdr: command failed").ok).toBe(false);
+ });
+
+ test("handles result-less payloads", () => {
+ expect(parseEnvelope(JSON.stringify({ id: "x" })).ok).toBe(false);
+ });
+});
+
+describe("errorFromFailure", () => {
+ test("unwraps the error envelope herdr writes to stderr", () => {
+ const stderr = JSON.stringify({ error: { code: "pane_not_found", message: "pane zz:p9 not found" }, id: "cli:pane:read" });
+ expect(errorFromFailure(1, stderr)).toBe("pane zz:p9 not found");
+ });
+
+ test("falls back to raw stderr, then to the exit code", () => {
+ expect(errorFromFailure(127, "herdr: not found")).toBe("herdr: not found");
+ expect(errorFromFailure(2, " ")).toBe("herdr exited with 2");
+ });
+});
+
+describe("isRawOutput", () => {
+ test("only agent-read returns raw terminal text", () => {
+ expect(isRawOutput("agent-read")).toBe(true);
+ expect(isRawOutput("agent-list")).toBe(false);
+ expect(isRawOutput("pane-current")).toBe(false);
+ });
+});
+
+// ============================================================================
+// Result parsing
+// ============================================================================
+
+describe("parseAgents", () => {
+ test("maps snake_case fields", () => {
+ const [first, second] = parseAgents(AGENT_LIST);
+ expect(first).toEqual({
+ agent: "pi",
+ status: "working",
+ paneId: "w2:p1",
+ tabId: "w2:t1",
+ workspaceId: "w2",
+ cwd: "/home/u/src/home",
+ focused: false,
+ title: "π home",
+ sessionPath: "/home/u/.pi/s.jsonl",
+ });
+ expect(second.focused).toBe(true);
+ expect(second.sessionPath).toBe("");
+ expect(second.cwd).toBe("/home/u/desktop/org"); // falls back to cwd
+ });
+
+ test("returns [] on garbage", () => {
+ expect(parseAgents(undefined)).toEqual([]);
+ expect(parseAgents({ agents: "nope" })).toEqual([]);
+ });
+
+ test("normalizes unknown status", () => {
+ expect(parseAgents({ agents: [{ agent_status: "exploded" }] })[0].status).toBe("unknown");
+ });
+});
+
+describe("parsePane / parseWorkspaces", () => {
+ test("parsePane prefers foreground_cwd", () => {
+ const pane = parsePane({ pane_id: "w2:p1", cwd: "/a", foreground_cwd: "/b", agent_status: "idle", focused: true });
+ expect(pane.paneId).toBe("w2:p1");
+ expect(pane.cwd).toBe("/b");
+ expect(pane.focused).toBe(true);
+ });
+
+ test("parseWorkspaces maps counts", () => {
+ const workspaces = parseWorkspaces(WORKSPACE_LIST);
+ expect(workspaces).toHaveLength(2);
+ expect(workspaces[0]).toMatchObject({ workspaceId: "w2", label: "home", tabCount: 3, paneCount: 3 });
+ expect(workspaces[1].focused).toBe(true);
+ });
+
+ test("parseWorkspaces returns [] on garbage", () => {
+ expect(parseWorkspaces({})).toEqual([]);
+ });
+});
+
+describe("id extraction", () => {
+ // Captured from `herdr workspace create` / `tab create` / `pane split`.
+ const WORKSPACE_CREATED = {
+ type: "workspace_created",
+ root_pane: { pane_id: "wB:p1", tab_id: "wB:t1", workspace_id: "wB", cwd: "/tmp" },
+ tab: { tab_id: "wB:t1", label: "1", workspace_id: "wB" },
+ workspace: { workspace_id: "wB", label: "pi-ext-test", active_tab_id: "wB:t1" },
+ };
+ const TAB_CREATED = {
+ type: "tab_created",
+ root_pane: { pane_id: "wB:p2", tab_id: "wB:t2", workspace_id: "wB" },
+ tab: { tab_id: "wB:t2", label: "2", workspace_id: "wB" },
+ };
+ const PANE_SPLIT = {
+ type: "pane_info",
+ pane: { pane_id: "wB:p3", tab_id: "wB:t1", workspace_id: "wB" },
+ };
+
+ test("finds pane id in every creation shape", () => {
+ expect(extractPaneId(WORKSPACE_CREATED)).toBe("wB:p1");
+ expect(extractPaneId(TAB_CREATED)).toBe("wB:p2");
+ expect(extractPaneId(PANE_SPLIT)).toBe("wB:p3");
+ expect(extractPaneId({ pane_id: "w3:p2" })).toBe("w3:p2");
+ expect(extractPaneId({})).toBeUndefined();
+ });
+
+ test("finds workspace id", () => {
+ expect(extractWorkspaceId(WORKSPACE_CREATED)).toBe("wB");
+ expect(extractWorkspaceId(TAB_CREATED)).toBe("wB");
+ expect(extractWorkspaceId(PANE_SPLIT)).toBe("wB");
+ expect(extractWorkspaceId({})).toBeUndefined();
+ });
+
+ test("formatCreated summarises ids instead of dumping JSON", () => {
+ expect(formatCreated("workspace-create", WORKSPACE_CREATED)).toBe(
+ 'workspace-create: ok, pane wB:p1, workspace wB, label "pi-ext-test"',
+ );
+ expect(formatCreated("pane-split", PANE_SPLIT)).toBe("pane-split: ok, pane wB:p3, workspace wB");
+ expect(formatCreated("pane-run", {})).toBe("pane-run: ok");
+ });
+});
+
+describe("parseResponse", () => {
+ test("non-zero exit reports the stderr envelope message", () => {
+ const stderr = JSON.stringify({ error: { code: "pane_not_found", message: "pane zz:p9 not found" } });
+ expect(parseResponse(1, "", stderr)).toEqual({ ok: false, error: "pane zz:p9 not found" });
+ });
+
+ test("silent success (e.g. pane run) is not an error", () => {
+ expect(parseResponse(0, "", "")).toEqual({ ok: true, result: {} });
+ });
+
+ test("raw mode returns terminal text untouched", () => {
+ expect(parseResponse(0, "line1\nline2\n", "", true)).toEqual({ ok: true, result: "line1\nline2\n" });
+ });
+
+ test("otherwise unwraps the envelope", () => {
+ const res = parseResponse(0, JSON.stringify({ id: "x", result: { type: "pane_info" } }), "");
+ expect(res.ok).toBe(true);
+ if (res.ok) expect(res.result.type).toBe("pane_info");
+ });
+});
+
+// ============================================================================
+// Argument building
+// ============================================================================
+
+describe("buildArgs: read actions", () => {
+ test("list actions", () => {
+ expect(buildArgs({ action: "agent-list" })).toEqual(["agent", "list"]);
+ expect(buildArgs({ action: "workspace-list" })).toEqual(["workspace", "list"]);
+ expect(buildArgs({ action: "pane-current" })).toEqual(["pane", "current"]);
+ });
+
+ test("agent-read defaults to 100 lines", () => {
+ expect(buildArgs({ action: "agent-read", target: "w2:p1" })).toEqual([
+ "pane", "read", "w2:p1", "--lines", "100",
+ ]);
+ expect(buildArgs({ action: "agent-read", target: "w2:p1", lines: 20, source: "visible" })).toEqual([
+ "pane", "read", "w2:p1", "--lines", "20", "--source", "visible",
+ ]);
+ });
+
+ test("agent-wait repeats --until", () => {
+ expect(buildArgs({ action: "agent-wait", target: "w2:p1", until: ["idle", "blocked"], timeout: 5000 })).toEqual([
+ "agent", "wait", "w2:p1", "--until", "idle", "--until", "blocked", "--timeout", "5000",
+ ]);
+ });
+});
+
+describe("buildArgs: write actions", () => {
+ test("agent-prompt without wait", () => {
+ expect(buildArgs({ action: "agent-prompt", target: "w2:p1", text: "run the tests" })).toEqual([
+ "agent", "prompt", "w2:p1", "run the tests",
+ ]);
+ });
+
+ test("agent-prompt adds --wait when until/timeout given", () => {
+ expect(buildArgs({ action: "agent-prompt", target: "w2:p1", text: "go", until: ["idle"] })).toEqual([
+ "agent", "prompt", "w2:p1", "go", "--wait", "--until", "idle",
+ ]);
+ expect(buildArgs({ action: "agent-prompt", target: "w2:p1", text: "go", timeout: 1000 })).toEqual([
+ "agent", "prompt", "w2:p1", "go", "--wait", "--timeout", "1000",
+ ]);
+ });
+
+ test("workspace-create with label, cwd, env and focus", () => {
+ expect(buildArgs({ action: "workspace-create", cwd: "/src/x", label: "x", env: ["A=1", "B=2"], focus: true })).toEqual([
+ "workspace", "create", "--cwd", "/src/x", "--label", "x", "--env", "A=1", "--env", "B=2", "--focus",
+ ]);
+ });
+
+ test("focus:false becomes --no-focus", () => {
+ expect(buildArgs({ action: "tab-create", workspace: "w2", focus: false })).toEqual([
+ "tab", "create", "--workspace", "w2", "--no-focus",
+ ]);
+ });
+
+ test("pane-split defaults to current pane and right", () => {
+ expect(buildArgs({ action: "pane-split" })).toEqual(["pane", "split", "--current", "--direction", "right"]);
+ expect(buildArgs({ action: "pane-split", target: "w2:p1", direction: "down" })).toEqual([
+ "pane", "split", "w2:p1", "--direction", "down",
+ ]);
+ });
+
+ test("pane-run passes command verbatim", () => {
+ expect(buildArgs({ action: "pane-run", target: "w2:p3", command: ["make", "test"] })).toEqual([
+ "pane", "run", "w2:p3", "make", "test",
+ ]);
+ });
+
+ test("agent-start uses name or kind", () => {
+ expect(buildArgs({ action: "agent-start", kind: "pi", target: "w2:p3" })).toEqual([
+ "agent", "start", "pi", "--kind", "pi", "--pane", "w2:p3",
+ ]);
+ expect(buildArgs({ action: "agent-start", kind: "claude", name: "reviewer", target: "w2:p3", timeout: 60000 })).toEqual([
+ "agent", "start", "reviewer", "--kind", "claude", "--pane", "w2:p3", "--timeout", "60000",
+ ]);
+ });
+
+ test("worktree-create requests JSON", () => {
+ expect(buildArgs({ action: "worktree-create", branch: "feat/x", base: "main", cwd: "/src/home" })).toEqual([
+ "worktree", "create", "--json", "--branch", "feat/x", "--base", "main", "--cwd", "/src/home",
+ ]);
+ });
+
+ test("notify", () => {
+ expect(buildArgs({ action: "notify", text: "Done", body: "tests passed" })).toEqual([
+ "notification", "show", "Done", "--body", "tests passed",
+ ]);
+ });
+});
+
+describe("buildArgs: validation", () => {
+ test("missing required params throw with actionable message", () => {
+ expect(() => buildArgs({ action: "agent-read" })).toThrow('agent-read requires "target"');
+ expect(() => buildArgs({ action: "agent-prompt", target: "w2:p1" })).toThrow('requires "text"');
+ expect(() => buildArgs({ action: "pane-run", target: "w2:p1" })).toThrow('requires "command"');
+ expect(() => buildArgs({ action: "pane-run", target: "w2:p1", command: [] })).toThrow('requires "command"');
+ expect(() => buildArgs({ action: "agent-start", target: "w2:p1" })).toThrow('requires "kind"');
+ expect(() => buildArgs({ action: "agent-start", kind: "pi" })).toThrow('requires "target"');
+ expect(() => buildArgs({ action: "worktree-create" })).toThrow('requires "branch"');
+ expect(() => buildArgs({ action: "notify" })).toThrow('requires "text"');
+ expect(() => buildArgs({ action: "bogus" } as any)).toThrow("unknown action");
+ });
+});
+
+// ============================================================================
+// Approval classification
+// ============================================================================
+
+describe("isWriteAction", () => {
+ test("mutating actions require approval", () => {
+ for (const action of ["agent-prompt", "workspace-create", "tab-create", "pane-split", "pane-run", "agent-start", "worktree-create"] as const) {
+ expect(isWriteAction(action)).toBe(true);
+ }
+ });
+
+ test("read-only actions do not", () => {
+ for (const action of ["agent-list", "agent-read", "pane-current", "workspace-list", "agent-wait", "notify"] as const) {
+ expect(isWriteAction(action)).toBe(false);
+ }
+ });
+
+ test("confirmations mention the target", () => {
+ expect(buildConfirmation({ action: "agent-prompt", target: "w2:p1", text: "hi" })).toContain("w2:p1");
+ expect(buildConfirmation({ action: "pane-run", target: "w2:p3", command: ["make", "test"] })).toContain("make test");
+ expect(buildConfirmation({ action: "worktree-create", branch: "feat/x" })).toContain("feat/x");
+ });
+});
+
+// ============================================================================
+// Formatting
+// ============================================================================
+
+describe("formatting", () => {
+ test("truncate", () => {
+ expect(truncate("abc", 10)).toBe("abc");
+ expect(truncate("abcdefghij", 6)).toBe("abc...");
+ });
+
+ test("statusIcon covers every state", () => {
+ expect(statusIcon("working")).toBe("⏳");
+ expect(statusIcon("blocked")).toBe("⚠");
+ expect(statusIcon("idle")).toBe("●");
+ expect(statusIcon("done")).toBe("✓");
+ expect(statusIcon("unknown")).toBe("·");
+ });
+
+ test("formatAgents renders a table and marks the focused agent", () => {
+ const out = formatAgents(parseAgents(AGENT_LIST));
+ expect(out).toContain("w2:p1");
+ expect(out).toContain("working");
+ expect(out.split("\n")).toHaveLength(4); // header + separator + 2 rows
+ expect(out).toContain("| → | w8:p2");
+ });
+
+ test("empty lists produce readable text", () => {
+ expect(formatAgents([])).toBe("No agents running in Herdr.");
+ expect(formatWorkspaces([])).toBe("No workspaces.");
+ });
+
+ test("formatWorkspaces lists ids and labels", () => {
+ const out = formatWorkspaces(parseWorkspaces(WORKSPACE_LIST));
+ expect(out).toContain("home");
+ expect(out).toContain("community");
+ });
+
+ test("formatPane omits missing agent info", () => {
+ const out = formatPane(parsePane({ pane_id: "w2:p2", cwd: "/tmp", agent_status: "unknown" }));
+ expect(out).toContain("pane: w2:p2");
+ expect(out).not.toContain("agent:");
+ });
+});
dots/pi/agent/extensions/herdr/index.ts
@@ -0,0 +1,191 @@
+/**
+ * Pi Extension: Herdr
+ *
+ * Interact with the Herdr terminal workspace manager from inside a Pi session:
+ * inspect agents/workspaces/panes, spawn new workspaces, tabs, panes, worktrees,
+ * launch commands or agents in them, and prompt/wait on sibling agents.
+ *
+ * All state-changing actions go through an approval dialog.
+ * The extension is inert when Pi is not running inside Herdr (HERDR_ENV != 1).
+ *
+ * Requirements: herdr on PATH, with a running Herdr server.
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@earendil-works/pi-ai";
+
+import type { HerdrDetails } from "./types";
+import {
+ approvalGate,
+ buildArgs,
+ buildConfirmation,
+ currentPaneId,
+ extractPaneId,
+ extractWorkspaceId,
+ formatAgents,
+ formatCreated,
+ formatPane,
+ formatWorkspaces,
+ isInsideHerdr,
+ isRawOutput,
+ isWriteAction,
+ parseAgents,
+ parsePane,
+ parseResponse,
+ parseWorkspaces,
+ type HerdrParams,
+} from "./utils";
+
+const TIMEOUT_MS = 60_000;
+
+function textResult(text: string, details: HerdrDetails) {
+ return { content: [{ type: "text" as const, text }], details };
+}
+
+export default function (pi: ExtensionAPI) {
+ if (!isInsideHerdr()) return;
+
+ /**
+ * Run `herdr <args>`. Failures are reported on stderr as a JSON envelope
+ * with a non-zero exit code; `pane read` answers with raw terminal text.
+ */
+ async function run(args: string[], signal?: AbortSignal, raw = false) {
+ const result = await pi.exec("herdr", args, { signal, timeout: TIMEOUT_MS });
+ return parseResponse(result.code, result.stdout, result.stderr, raw);
+ }
+
+ pi.registerTool({
+ name: "herdr",
+ label: "Herdr",
+ description:
+ "Control the Herdr terminal workspace this session runs in. " +
+ "Read: agent-list, agent-read, workspace-list, pane-current, agent-wait. " +
+ "Create: workspace-create, tab-create, pane-split, worktree-create — each returns a paneId " +
+ "usable with pane-run (run a command) or agent-start (launch pi/claude/codex/...). " +
+ "Interact: agent-prompt (send a prompt to another agent), notify (desktop/toast notification). " +
+ "Creating and interacting require user approval; call them ONE AT A TIME, never in parallel. " +
+ "Targets are pane ids like 'w2:p1' (see agent-list / pane-current).",
+
+ parameters: Type.Object({
+ action: StringEnum([
+ "agent-list",
+ "agent-read",
+ "pane-current",
+ "workspace-list",
+ "agent-wait",
+ "workspace-create",
+ "tab-create",
+ "pane-split",
+ "pane-run",
+ "agent-start",
+ "worktree-create",
+ "agent-prompt",
+ "notify",
+ ] as const),
+
+ target: Type.Optional(Type.String({ description: "Pane id, e.g. 'w2:p1' (agent-read/wait/prompt, pane-run, pane-split, agent-start)" })),
+ text: Type.Optional(Type.String({ description: "Prompt text (agent-prompt) or notification title (notify)" })),
+ body: Type.Optional(Type.String({ description: "Notification body (notify)" })),
+
+ lines: Type.Optional(Type.Number({ description: "Lines of terminal output to read (agent-read, default 100)" })),
+ source: Type.Optional(Type.String({ description: "Snapshot source for agent-read: visible, recent (default), recent-unwrapped, detection" })),
+
+ until: Type.Optional(Type.Array(Type.String(), { description: "States to wait for: idle, working, blocked, done, unknown (agent-wait, agent-prompt)" })),
+ timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds (agent-wait, agent-prompt, agent-start)" })),
+
+ cwd: Type.Optional(Type.String({ description: "Working directory for the new workspace/tab/pane, or repo path for worktree-create" })),
+ label: Type.Optional(Type.String({ description: "Display label for the new workspace/tab/worktree" })),
+ workspace: Type.Optional(Type.String({ description: "Workspace id for tab-create" })),
+ direction: Type.Optional(StringEnum(["right", "down"] as const, { description: "Split direction (pane-split, default right)" })),
+ focus: Type.Optional(Type.Boolean({ description: "Focus the created workspace/tab/pane" })),
+ env: Type.Optional(Type.Array(Type.String(), { description: "Environment variables as KEY=VALUE for the launched process" })),
+
+ command: Type.Optional(Type.Array(Type.String(), { description: "Command and arguments to run (pane-run)" })),
+
+ kind: Type.Optional(Type.String({ description: "Agent kind for agent-start: pi, claude, codex, gemini, cursor, opencode, copilot, ..." })),
+ name: Type.Optional(Type.String({ description: "Display name for the started agent (agent-start, defaults to kind)" })),
+
+ branch: Type.Optional(Type.String({ description: "Branch name (worktree-create)" })),
+ base: Type.Optional(Type.String({ description: "Base ref for the new branch (worktree-create)" })),
+ }),
+
+ async execute(_toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) {
+ const p = params as HerdrParams;
+ const details: HerdrDetails = { action: p.action };
+
+ let args: string[];
+ try {
+ args = buildArgs(p);
+ } catch (err: any) {
+ return textResult(`Error: ${err.message}`, { ...details, error: err.message });
+ }
+
+ if (isWriteAction(p.action)) {
+ const approved = await approvalGate(ctx, buildConfirmation(p));
+ if (!approved) {
+ return textResult(
+ `User rejected this herdr ${p.action}. Do NOT retry.`,
+ { ...details, cancelled: true },
+ );
+ }
+ }
+
+ const res = await run(args, signal, isRawOutput(p.action));
+ if (!res.ok) {
+ return textResult(`Error: ${res.error}`, { ...details, error: res.error });
+ }
+
+ switch (p.action) {
+ case "agent-list": {
+ const agents = parseAgents(res.result);
+ return textResult(formatAgents(agents), { ...details, count: agents.length });
+ }
+ case "workspace-list": {
+ const workspaces = parseWorkspaces(res.result);
+ return textResult(formatWorkspaces(workspaces), { ...details, count: workspaces.length });
+ }
+ case "pane-current": {
+ const pane = parsePane(res.result?.pane ?? res.result);
+ return textResult(formatPane(pane), { ...details, paneId: pane.paneId });
+ }
+ case "agent-read": {
+ const text = String(res.result).trimEnd();
+ return textResult(text || "(no output)", { ...details, paneId: p.target });
+ }
+ default: {
+ const paneId = extractPaneId(res.result);
+ const workspaceId = extractWorkspaceId(res.result);
+ return textResult(formatCreated(p.action, res.result), { ...details, paneId, workspaceId });
+ }
+ }
+ },
+ });
+
+ // /herdr — quick overview of workspaces and agents
+ pi.registerCommand("herdr", {
+ description: "Show Herdr workspaces and running agents",
+ handler: async (_args, ctx) => {
+ if (!ctx.hasUI) {
+ ctx.ui.notify("/herdr requires interactive mode", "error");
+ return;
+ }
+
+ const [agents, workspaces] = await Promise.all([
+ run(["agent", "list"]),
+ run(["workspace", "list"]),
+ ]);
+
+ const lines = ["## Herdr", ""];
+ const self = currentPaneId();
+ if (self) lines.push(`This session runs in pane \`${self}\`.`, "");
+
+ lines.push("### Workspaces", "");
+ lines.push(workspaces.ok ? formatWorkspaces(parseWorkspaces(workspaces.result)) : `Error: ${workspaces.error}`);
+ lines.push("", "### Agents", "");
+ lines.push(agents.ok ? formatAgents(parseAgents(agents.result)) : `Error: ${agents.error}`);
+
+ pi.sendMessage({ customType: "herdr-overview", content: lines.join("\n"), display: true });
+ },
+ });
+}
dots/pi/agent/extensions/herdr/Makefile
@@ -0,0 +1,21 @@
+.PHONY: test test-watch help
+
+# Run tests
+test:
+ @echo "Running tests..."
+ @bun test herdr.test.ts
+
+# Run tests in watch mode
+test-watch:
+ @echo "Running tests in watch mode..."
+ @bun test --watch herdr.test.ts
+
+# Help
+help:
+ @echo "Available targets:"
+ @echo " test - Run tests once"
+ @echo " test-watch - Run tests in watch mode"
+ @echo " help - Show this help message"
+
+# Default target
+.DEFAULT_GOAL := help
dots/pi/agent/extensions/herdr/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "herdr-extension",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "test": "bun test herdr.test.ts"
+ },
+ "devDependencies": {
+ "@earendil-works/pi-coding-agent": "*",
+ "bun-types": "^1.0.0"
+ }
+}
dots/pi/agent/extensions/herdr/README.md
@@ -0,0 +1,66 @@
+# Herdr extension
+
+Interact with the [Herdr](https://herdr.dev) terminal workspace manager from
+inside a Pi session: inspect sibling agents, spawn workspaces/tabs/panes/worktrees,
+launch commands or agents in them, and prompt or wait on other agents.
+
+The extension registers nothing when Pi is not running inside Herdr
+(`HERDR_ENV != 1`), so it is inert outside a Herdr pane.
+
+Not to be confused with `../herdr-agent-state.ts`, which is installed and
+overwritten by Herdr itself (it reports this pane's agent state back to Herdr).
+That file is managed by `herdr integration`; do not edit it.
+
+## Requirements
+
+- `herdr` on `PATH`, with a running Herdr server.
+
+## Tool: `herdr`
+
+| Action | Params | Notes |
+|---|---|---|
+| `agent-list` | — | All agents with status, pane id and cwd |
+| `workspace-list` | — | Workspaces with status and counts |
+| `pane-current` | — | Which pane/tab/workspace this session runs in |
+| `agent-read` | `target`, `lines`, `source` | Terminal output of a pane |
+| `agent-wait` | `target`, `until[]`, `timeout` | Block until an agent settles |
+| `agent-prompt` | `target`, `text`, `until[]`, `timeout` | Send a prompt; `until`/`timeout` imply `--wait` |
+| `workspace-create` | `cwd`, `label`, `env[]`, `focus` | Returns the new pane id |
+| `tab-create` | `workspace`, `cwd`, `label`, `env[]`, `focus` | Returns the new pane id |
+| `pane-split` | `target` (default: current), `direction`, `cwd`, `env[]`, `focus` | Returns the new pane id |
+| `worktree-create` | `branch`, `base`, `cwd`, `label`, `focus` | Git worktree in its own workspace |
+| `pane-run` | `target`, `command[]` | Run a command in an existing pane |
+| `agent-start` | `target`, `kind`, `name`, `timeout` | Launch pi/claude/codex/... in a pane at a shell prompt |
+| `notify` | `text` (title), `body` | Herdr notification |
+
+Typical spawn flow: `workspace-create` (or `pane-split` / `worktree-create`)
+→ take `paneId` from the result → `pane-run` or `agent-start` in it.
+
+All state-changing actions (everything except the reads and `notify`) go through
+an approval dialog. Approvals are serialized through a mutex, because parallel
+`ui.select()` dialogs deadlock the TUI — call write actions one at a time.
+
+## Command
+
+- `/herdr` — overview of workspaces and running agents, plus this pane's id.
+
+## Implementation notes
+
+Herdr's CLI answers with a JSON envelope on stdout
+(`{"id": ..., "result": {...}}`), with three exceptions handled in
+`parseResponse()`:
+
+- errors arrive on **stderr** as an envelope with a non-zero exit code;
+- `pane read` returns **raw terminal text**;
+- action commands such as `pane run` succeed **silently** (exit 0, no stdout).
+
+Creation responses use different shapes (`workspace`/`root_pane` vs `pane`);
+`extractPaneId()` / `extractWorkspaceId()` normalize them.
+
+## Tests
+
+```sh
+make test # bun test herdr.test.ts
+```
+
+Fixtures are captured from the real CLI.
dots/pi/agent/extensions/herdr/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": [
+ "bun-types"
+ ]
+ },
+ "include": [
+ "*.ts"
+ ]
+}
dots/pi/agent/extensions/herdr/types.ts
@@ -0,0 +1,70 @@
+/**
+ * Types for the Herdr extension.
+ *
+ * Herdr's CLI always answers with a JSON envelope on stdout:
+ * { "id": "cli:agent:list", "result": { "type": "agent_list", ... } }
+ * or { "id": "...", "error": { "code": ..., "message": ... } }
+ */
+
+export type HerdrAction =
+ // read
+ | "agent-list"
+ | "agent-read"
+ | "pane-current"
+ | "workspace-list"
+ | "agent-wait"
+ // create / spawn (approval required)
+ | "workspace-create"
+ | "tab-create"
+ | "pane-split"
+ | "pane-run"
+ | "agent-start"
+ | "worktree-create"
+ // interact (approval required)
+ | "agent-prompt"
+ | "notify";
+
+export type AgentStatus = "idle" | "working" | "blocked" | "done" | "unknown";
+
+export interface HerdrAgent {
+ agent: string;
+ status: AgentStatus;
+ paneId: string;
+ tabId: string;
+ workspaceId: string;
+ cwd: string;
+ focused: boolean;
+ title: string;
+ sessionPath: string;
+}
+
+export interface HerdrPane {
+ paneId: string;
+ tabId: string;
+ workspaceId: string;
+ cwd: string;
+ focused: boolean;
+ agent: string;
+ status: AgentStatus;
+ title: string;
+}
+
+export interface HerdrWorkspace {
+ workspaceId: string;
+ label: string;
+ number: number;
+ status: AgentStatus;
+ focused: boolean;
+ paneCount: number;
+ tabCount: number;
+}
+
+/** Structured details attached to tool results (used by the renderer). */
+export interface HerdrDetails {
+ action: HerdrAction;
+ paneId?: string;
+ workspaceId?: string;
+ count?: number;
+ cancelled?: boolean;
+ error?: string;
+}
dots/pi/agent/extensions/herdr/utils.ts
@@ -0,0 +1,434 @@
+/**
+ * Pure helpers for the Herdr extension: argument building, envelope parsing,
+ * formatting and the approval gate. Everything here is unit-testable.
+ */
+
+import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
+import type {
+ AgentStatus,
+ HerdrAction,
+ HerdrAgent,
+ HerdrPane,
+ HerdrWorkspace,
+} from "./types";
+
+// ============================================================================
+// Environment
+// ============================================================================
+
+/** Herdr sets HERDR_ENV=1 in every pane it spawns. */
+export function isInsideHerdr(env: NodeJS.ProcessEnv = process.env): boolean {
+ return env.HERDR_ENV === "1";
+}
+
+/** Pane id of the pane this agent runs in, when known. */
+export function currentPaneId(env: NodeJS.ProcessEnv = process.env): string | undefined {
+ return env.HERDR_PANE_ID || undefined;
+}
+
+// ============================================================================
+// Envelope parsing
+// ============================================================================
+
+export type Envelope = { ok: true; result: any } | { ok: false; error: string };
+
+export function parseEnvelope(stdout: string): Envelope {
+ const text = stdout.trim();
+ if (!text) return { ok: false, error: "empty response from herdr" };
+ let data: any;
+ try {
+ data = JSON.parse(text);
+ } catch {
+ return { ok: false, error: `unparsable herdr response: ${text.slice(0, 200)}` };
+ }
+ if (data?.error) {
+ const err = data.error;
+ return { ok: false, error: err.message ?? JSON.stringify(err) };
+ }
+ if (data?.result === undefined) return { ok: false, error: "herdr response has no result" };
+ return { ok: true, result: data.result };
+}
+
+/** Turn a non-zero herdr exit into a readable message (errors arrive on stderr). */export function errorFromFailure(code: number, stderr: string): string {
+ const text = stderr.trim();
+ if (text) {
+ try {
+ const data = JSON.parse(text);
+ if (data?.error?.message) return data.error.message;
+ } catch {
+ // not an envelope; fall through to the raw text
+ }
+ return text;
+ }
+ return `herdr exited with ${code}`;
+}
+
+/**
+ * Interpret a completed `herdr` invocation.
+ *
+ * Herdr answers on stdout with a JSON envelope, except `pane read` (raw
+ * terminal text) and the action commands (`pane run`), which succeed silently.
+ * Errors arrive on stderr as an envelope with a non-zero exit code.
+ */
+export function parseResponse(
+ code: number,
+ stdout: string,
+ stderr: string,
+ raw = false,
+): Envelope {
+ if (code !== 0) return { ok: false, error: errorFromFailure(code, stderr) };
+ if (raw) return { ok: true, result: stdout };
+ if (!stdout.trim()) return { ok: true, result: {} };
+ return parseEnvelope(stdout);
+}
+
+function status(value: unknown): AgentStatus {
+ switch (value) {
+ case "idle":
+ case "working":
+ case "blocked":
+ case "done":
+ return value;
+ default:
+ return "unknown";
+ }
+}
+
+export function parseAgents(result: any): HerdrAgent[] {
+ const agents = result?.agents;
+ if (!Array.isArray(agents)) return [];
+ return agents.map((a: any) => ({
+ agent: a.agent ?? "",
+ status: status(a.agent_status),
+ paneId: a.pane_id ?? "",
+ tabId: a.tab_id ?? "",
+ workspaceId: a.workspace_id ?? "",
+ cwd: a.foreground_cwd ?? a.cwd ?? "",
+ focused: a.focused === true,
+ title: a.terminal_title_stripped ?? a.terminal_title ?? "",
+ sessionPath: a.agent_session?.kind === "path" ? (a.agent_session.value ?? "") : "",
+ }));
+}
+
+export function parsePane(pane: any): HerdrPane {
+ return {
+ paneId: pane?.pane_id ?? "",
+ tabId: pane?.tab_id ?? "",
+ workspaceId: pane?.workspace_id ?? "",
+ cwd: pane?.foreground_cwd ?? pane?.cwd ?? "",
+ focused: pane?.focused === true,
+ agent: pane?.agent ?? "",
+ status: status(pane?.agent_status),
+ title: pane?.terminal_title_stripped ?? pane?.terminal_title ?? "",
+ };
+}
+
+export function parseWorkspaces(result: any): HerdrWorkspace[] {
+ const workspaces = result?.workspaces;
+ if (!Array.isArray(workspaces)) return [];
+ return workspaces.map((w: any) => ({
+ workspaceId: w.workspace_id ?? "",
+ label: w.label ?? "",
+ number: w.number ?? 0,
+ status: status(w.agent_status),
+ focused: w.focused === true,
+ paneCount: w.pane_count ?? 0,
+ tabCount: w.tab_count ?? 0,
+ }));
+}
+
+/** Pull a pane id out of any creation response (workspace/tab/pane/worktree). */
+export function extractPaneId(result: any): string | undefined {
+ return (
+ result?.pane?.pane_id ??
+ result?.root_pane?.pane_id ??
+ result?.pane_id ??
+ undefined
+ );
+}
+
+export function extractWorkspaceId(result: any): string | undefined {
+ return (
+ result?.workspace?.workspace_id ??
+ result?.root_pane?.workspace_id ??
+ result?.pane?.workspace_id ??
+ result?.workspace_id ??
+ undefined
+ );
+}
+
+/** One-line summary of a creation/action response, with the ids that matter. */
+export function formatCreated(action: HerdrAction, result: any): string {
+ const paneId = extractPaneId(result);
+ const workspaceId = extractWorkspaceId(result);
+ if (!paneId && !workspaceId) return `${action}: ok`;
+ const parts = [`${action}: ok`];
+ if (paneId) parts.push(`pane ${paneId}`);
+ if (workspaceId) parts.push(`workspace ${workspaceId}`);
+ const label = result?.workspace?.label ?? result?.tab?.label;
+ if (label) parts.push(`label "${label}"`);
+ return parts.join(", ");
+}
+
+// ============================================================================
+// Argument building
+// ============================================================================
+
+export interface HerdrParams {
+ action: HerdrAction;
+ target?: string;
+ text?: string;
+ lines?: number;
+ source?: string;
+ until?: string[];
+ timeout?: number;
+ cwd?: string;
+ label?: string;
+ branch?: string;
+ base?: string;
+ direction?: string;
+ workspace?: string;
+ command?: string[];
+ kind?: string;
+ name?: string;
+ focus?: boolean;
+ body?: string;
+ env?: string[];
+}
+
+function optional(args: string[], flag: string, value: unknown) {
+ if (value === undefined || value === null || value === "") return;
+ args.push(flag, String(value));
+}
+
+function focusFlag(args: string[], focus: boolean | undefined) {
+ if (focus === true) args.push("--focus");
+ else if (focus === false) args.push("--no-focus");
+}
+
+function envFlags(args: string[], env: string[] | undefined) {
+ for (const pair of env ?? []) args.push("--env", pair);
+}
+
+/** Build the herdr CLI argv for an action. Throws on missing required params. */
+export function buildArgs(p: HerdrParams): string[] {
+ const need = (value: string | undefined, what: string): string => {
+ if (!value) throw new Error(`${p.action} requires "${what}"`);
+ return value;
+ };
+
+ switch (p.action) {
+ case "agent-list":
+ return ["agent", "list"];
+
+ case "workspace-list":
+ return ["workspace", "list"];
+
+ case "pane-current":
+ return ["pane", "current"];
+
+ case "agent-read": {
+ const args = ["pane", "read", need(p.target, "target")];
+ optional(args, "--lines", p.lines ?? 100);
+ optional(args, "--source", p.source);
+ return args;
+ }
+
+ case "agent-wait": {
+ const args = ["agent", "wait", need(p.target, "target")];
+ for (const state of p.until ?? []) args.push("--until", state);
+ optional(args, "--timeout", p.timeout);
+ return args;
+ }
+
+ case "agent-prompt": {
+ const args = ["agent", "prompt", need(p.target, "target"), need(p.text, "text")];
+ if (p.until?.length || p.timeout) {
+ args.push("--wait");
+ for (const state of p.until ?? []) args.push("--until", state);
+ optional(args, "--timeout", p.timeout);
+ }
+ return args;
+ }
+
+ case "workspace-create": {
+ const args = ["workspace", "create"];
+ optional(args, "--cwd", p.cwd);
+ optional(args, "--label", p.label);
+ envFlags(args, p.env);
+ focusFlag(args, p.focus);
+ return args;
+ }
+
+ case "tab-create": {
+ const args = ["tab", "create"];
+ optional(args, "--workspace", p.workspace);
+ optional(args, "--cwd", p.cwd);
+ optional(args, "--label", p.label);
+ envFlags(args, p.env);
+ focusFlag(args, p.focus);
+ return args;
+ }
+
+ case "pane-split": {
+ const args = ["pane", "split"];
+ if (p.target) args.push(p.target);
+ else args.push("--current");
+ optional(args, "--direction", p.direction ?? "right");
+ optional(args, "--cwd", p.cwd);
+ envFlags(args, p.env);
+ focusFlag(args, p.focus);
+ return args;
+ }
+
+ case "pane-run": {
+ const command = p.command ?? [];
+ if (command.length === 0) throw new Error("pane-run requires \"command\"");
+ return ["pane", "run", need(p.target, "target"), ...command];
+ }
+
+ case "agent-start": {
+ const args = ["agent", "start", p.name ?? p.kind ?? "agent"];
+ args.push("--kind", need(p.kind, "kind"));
+ args.push("--pane", need(p.target, "target"));
+ optional(args, "--timeout", p.timeout);
+ return args;
+ }
+
+ case "worktree-create": {
+ const args = ["worktree", "create", "--json"];
+ optional(args, "--branch", need(p.branch, "branch"));
+ optional(args, "--base", p.base);
+ optional(args, "--cwd", p.cwd);
+ optional(args, "--label", p.label);
+ focusFlag(args, p.focus);
+ return args;
+ }
+
+ case "notify": {
+ const args = ["notification", "show", need(p.text, "text")];
+ optional(args, "--body", p.body);
+ return args;
+ }
+
+ default:
+ throw new Error(`unknown action: ${(p as HerdrParams).action}`);
+ }
+}
+
+/** Actions that change the workspace and therefore need user approval. */
+const WRITE_ACTIONS = new Set<HerdrAction>([
+ "agent-prompt",
+ "workspace-create",
+ "tab-create",
+ "pane-split",
+ "pane-run",
+ "agent-start",
+ "worktree-create",
+]);
+
+export function isWriteAction(action: HerdrAction): boolean {
+ return WRITE_ACTIONS.has(action);
+}
+
+/** Actions whose stdout is a raw terminal dump instead of a JSON envelope. */
+export function isRawOutput(action: HerdrAction): boolean {
+ return action === "agent-read";
+}
+
+/** Human-readable summary shown in the approval dialog. */
+export function buildConfirmation(p: HerdrParams): string {
+ switch (p.action) {
+ case "agent-prompt":
+ return `Send a prompt to agent ${p.target}:\n\n"${truncate(p.text ?? "", 300)}"`;
+ case "workspace-create":
+ return `Create a new workspace${p.label ? ` "${p.label}"` : ""}${p.cwd ? ` in ${p.cwd}` : ""}.`;
+ case "tab-create":
+ return `Create a new tab${p.label ? ` "${p.label}"` : ""}${p.cwd ? ` in ${p.cwd}` : ""}.`;
+ case "pane-split":
+ return `Split pane ${p.target ?? "(current)"} to the ${p.direction ?? "right"}${p.cwd ? ` in ${p.cwd}` : ""}.`;
+ case "pane-run":
+ return `Run in pane ${p.target}:\n\n${(p.command ?? []).join(" ")}`;
+ case "agent-start":
+ return `Start ${p.kind} in pane ${p.target}.`;
+ case "worktree-create":
+ return `Create git worktree for branch "${p.branch}"${p.base ? ` from ${p.base}` : ""}${p.cwd ? ` (repo ${p.cwd})` : ""}.`;
+ default:
+ return `Run herdr ${p.action}.`;
+ }
+}
+
+// ============================================================================
+// Formatting
+// ============================================================================
+
+export function truncate(text: string, maxLength: number): string {
+ if (text.length <= maxLength) return text;
+ return text.slice(0, Math.max(0, maxLength - 3)) + "...";
+}
+
+export function statusIcon(state: AgentStatus): string {
+ switch (state) {
+ case "working":
+ return "⏳";
+ case "blocked":
+ return "⚠";
+ case "idle":
+ return "●";
+ case "done":
+ return "✓";
+ default:
+ return "·";
+ }
+}
+
+export function formatAgents(agents: HerdrAgent[]): string {
+ if (agents.length === 0) return "No agents running in Herdr.";
+ const lines = ["| | Pane | Agent | Status | Cwd |", "|---|------|-------|--------|-----|"];
+ for (const a of agents) {
+ const focus = a.focused ? "→" : "";
+ lines.push(
+ `| ${focus} | ${a.paneId} | ${a.agent || "?"} | ${statusIcon(a.status)} ${a.status} | ${truncate(a.cwd, 48)} |`,
+ );
+ }
+ return lines.join("\n");
+}
+
+export function formatWorkspaces(workspaces: HerdrWorkspace[]): string {
+ if (workspaces.length === 0) return "No workspaces.";
+ const lines = ["| | Id | Label | Status | Tabs | Panes |", "|---|---|-------|--------|------|-------|"];
+ for (const w of workspaces) {
+ lines.push(
+ `| ${w.focused ? "→" : ""} | ${w.workspaceId} | ${w.label} | ${statusIcon(w.status)} ${w.status} | ${w.tabCount} | ${w.paneCount} |`,
+ );
+ }
+ return lines.join("\n");
+}
+
+export function formatPane(pane: HerdrPane): string {
+ return [
+ `pane: ${pane.paneId}${pane.focused ? " (focused)" : ""}`,
+ `tab: ${pane.tabId}`,
+ `workspace: ${pane.workspaceId}`,
+ `cwd: ${pane.cwd}`,
+ pane.agent ? `agent: ${pane.agent} (${pane.status})` : undefined,
+ pane.title ? `title: ${pane.title}` : undefined,
+ ]
+ .filter(Boolean)
+ .join("\n");
+}
+
+// ============================================================================
+// Approval gate (serialized: parallel ui.select dialogs deadlock the TUI)
+// ============================================================================
+
+let approvalMutex: Promise<void> = Promise.resolve();
+
+export async function approvalGate(ctx: ExtensionContext, prompt: string): Promise<boolean> {
+ return await new Promise<boolean>((resolve) => {
+ approvalMutex = approvalMutex.then(async () => {
+ const choice = await ctx.ui.select(prompt, ["✓ Accept", "✗ Reject"]);
+ resolve(choice === "✓ Accept");
+ });
+ });
+}