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}