main
  1/**
  2 * Pi Extension: Herdr
  3 *
  4 * Interact with the Herdr terminal workspace manager from inside a Pi session:
  5 * inspect agents/workspaces/panes, spawn new workspaces, tabs, panes, worktrees,
  6 * launch commands or agents in them, and prompt/wait on sibling agents.
  7 *
  8 * All state-changing actions go through an approval dialog.
  9 * The extension is inert when Pi is not running inside Herdr (HERDR_ENV != 1).
 10 *
 11 * Requirements: herdr on PATH, with a running Herdr server.
 12 */
 13
 14import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
 15import { Type } from "@sinclair/typebox";
 16import { StringEnum } from "@earendil-works/pi-ai";
 17
 18import type { HerdrDetails } from "./types";
 19import {
 20	approvalGate,
 21	buildArgs,
 22	buildConfirmation,
 23	currentPaneId,
 24	extractPaneId,
 25	extractWorkspaceId,
 26	formatAgents,
 27	formatCreated,
 28	formatPane,
 29	formatWorkspaces,
 30	isInsideHerdr,
 31	isRawOutput,
 32	isWriteAction,
 33	parseAgents,
 34	parsePane,
 35	parseResponse,
 36	parseWorkspaces,
 37	type HerdrParams,
 38} from "./utils";
 39
 40const TIMEOUT_MS = 60_000;
 41
 42function textResult(text: string, details: HerdrDetails) {
 43	return { content: [{ type: "text" as const, text }], details };
 44}
 45
 46export default function (pi: ExtensionAPI) {
 47	if (!isInsideHerdr()) return;
 48
 49	/**
 50	 * Run `herdr <args>`. Failures are reported on stderr as a JSON envelope
 51	 * with a non-zero exit code; `pane read` answers with raw terminal text.
 52	 */
 53	async function run(args: string[], signal?: AbortSignal, raw = false) {
 54		const result = await pi.exec("herdr", args, { signal, timeout: TIMEOUT_MS });
 55		return parseResponse(result.code, result.stdout, result.stderr, raw);
 56	}
 57
 58	pi.registerTool({
 59		name: "herdr",
 60		label: "Herdr",
 61		description:
 62			"Control the Herdr terminal workspace this session runs in. " +
 63			"Read: agent-list, agent-read, workspace-list, pane-current, agent-wait. " +
 64			"Create: workspace-create, tab-create, pane-split, worktree-create — each returns a paneId " +
 65			"usable with pane-run (run a command) or agent-start (launch pi/claude/codex/...). " +
 66			"Interact: agent-prompt (send a prompt to another agent), notify (desktop/toast notification). " +
 67			"Creating and interacting require user approval; call them ONE AT A TIME, never in parallel. " +
 68			"Targets are pane ids like 'w2:p1' (see agent-list / pane-current).",
 69
 70		parameters: Type.Object({
 71			action: StringEnum([
 72				"agent-list",
 73				"agent-read",
 74				"pane-current",
 75				"workspace-list",
 76				"agent-wait",
 77				"workspace-create",
 78				"tab-create",
 79				"pane-split",
 80				"pane-run",
 81				"agent-start",
 82				"worktree-create",
 83				"agent-prompt",
 84				"notify",
 85			] as const),
 86
 87			target: Type.Optional(Type.String({ description: "Pane id, e.g. 'w2:p1' (agent-read/wait/prompt, pane-run, pane-split, agent-start)" })),
 88			text: Type.Optional(Type.String({ description: "Prompt text (agent-prompt) or notification title (notify)" })),
 89			body: Type.Optional(Type.String({ description: "Notification body (notify)" })),
 90
 91			lines: Type.Optional(Type.Number({ description: "Lines of terminal output to read (agent-read, default 100)" })),
 92			source: Type.Optional(Type.String({ description: "Snapshot source for agent-read: visible, recent (default), recent-unwrapped, detection" })),
 93
 94			until: Type.Optional(Type.Array(Type.String(), { description: "States to wait for: idle, working, blocked, done, unknown (agent-wait, agent-prompt)" })),
 95			timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds (agent-wait, agent-prompt, agent-start)" })),
 96
 97			cwd: Type.Optional(Type.String({ description: "Working directory for the new workspace/tab/pane, or repo path for worktree-create" })),
 98			label: Type.Optional(Type.String({ description: "Display label for the new workspace/tab/worktree" })),
 99			workspace: Type.Optional(Type.String({ description: "Workspace id for tab-create" })),
100			direction: Type.Optional(StringEnum(["right", "down"] as const, { description: "Split direction (pane-split, default right)" })),
101			focus: Type.Optional(Type.Boolean({ description: "Focus the created workspace/tab/pane" })),
102			env: Type.Optional(Type.Array(Type.String(), { description: "Environment variables as KEY=VALUE for the launched process" })),
103
104			command: Type.Optional(Type.Array(Type.String(), { description: "Command and arguments to run (pane-run)" })),
105
106			kind: Type.Optional(Type.String({ description: "Agent kind for agent-start: pi, claude, codex, gemini, cursor, opencode, copilot, ..." })),
107			name: Type.Optional(Type.String({ description: "Display name for the started agent (agent-start, defaults to kind)" })),
108
109			branch: Type.Optional(Type.String({ description: "Branch name (worktree-create)" })),
110			base: Type.Optional(Type.String({ description: "Base ref for the new branch (worktree-create)" })),
111		}),
112
113		async execute(_toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) {
114			const p = params as HerdrParams;
115			const details: HerdrDetails = { action: p.action };
116
117			let args: string[];
118			try {
119				args = buildArgs(p);
120			} catch (err: any) {
121				return textResult(`Error: ${err.message}`, { ...details, error: err.message });
122			}
123
124			if (isWriteAction(p.action)) {
125				const approved = await approvalGate(ctx, buildConfirmation(p));
126				if (!approved) {
127					return textResult(
128						`User rejected this herdr ${p.action}. Do NOT retry.`,
129						{ ...details, cancelled: true },
130					);
131				}
132			}
133
134			const res = await run(args, signal, isRawOutput(p.action));
135			if (!res.ok) {
136				return textResult(`Error: ${res.error}`, { ...details, error: res.error });
137			}
138
139			switch (p.action) {
140				case "agent-list": {
141					const agents = parseAgents(res.result);
142					return textResult(formatAgents(agents), { ...details, count: agents.length });
143				}
144				case "workspace-list": {
145					const workspaces = parseWorkspaces(res.result);
146					return textResult(formatWorkspaces(workspaces), { ...details, count: workspaces.length });
147				}
148				case "pane-current": {
149					const pane = parsePane(res.result?.pane ?? res.result);
150					return textResult(formatPane(pane), { ...details, paneId: pane.paneId });
151				}
152				case "agent-read": {
153					const text = String(res.result).trimEnd();
154					return textResult(text || "(no output)", { ...details, paneId: p.target });
155				}
156				default: {
157					const paneId = extractPaneId(res.result);
158					const workspaceId = extractWorkspaceId(res.result);
159					return textResult(formatCreated(p.action, res.result), { ...details, paneId, workspaceId });
160				}
161			}
162		},
163	});
164
165	// /herdr — quick overview of workspaces and agents
166	pi.registerCommand("herdr", {
167		description: "Show Herdr workspaces and running agents",
168		handler: async (_args, ctx) => {
169			if (!ctx.hasUI) {
170				ctx.ui.notify("/herdr requires interactive mode", "error");
171				return;
172			}
173
174			const [agents, workspaces] = await Promise.all([
175				run(["agent", "list"]),
176				run(["workspace", "list"]),
177			]);
178
179			const lines = ["## Herdr", ""];
180			const self = currentPaneId();
181			if (self) lines.push(`This session runs in pane \`${self}\`.`, "");
182
183			lines.push("### Workspaces", "");
184			lines.push(workspaces.ok ? formatWorkspaces(parseWorkspaces(workspaces.result)) : `Error: ${workspaces.error}`);
185			lines.push("", "### Agents", "");
186			lines.push(agents.ok ? formatAgents(parseAgents(agents.result)) : `Error: ${agents.error}`);
187
188			pi.sendMessage({ customType: "herdr-overview", content: lines.join("\n"), display: true });
189		},
190	});
191}