Commit 4d7ccceda884

Vincent Demeester <vincent@sbr.pm>
2026-08-04 16:24:54
feat(pi): added herdr workspace extension
Exposed Herdr's socket API to Pi sessions so an agent can see sibling agents and spawn workspaces, panes or worktrees to delegate work into, instead of driving the terminal blindly. State-changing actions stay behind an approval dialog.
1 parent 7813d3e
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");
+		});
+	});
+}