main
1/**
2 * Pure helpers for the Herdr extension: argument building, envelope parsing,
3 * formatting and the approval gate. Everything here is unit-testable.
4 */
5
6import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7import type {
8 AgentStatus,
9 HerdrAction,
10 HerdrAgent,
11 HerdrPane,
12 HerdrWorkspace,
13} from "./types";
14
15// ============================================================================
16// Environment
17// ============================================================================
18
19/** Herdr sets HERDR_ENV=1 in every pane it spawns. */
20export function isInsideHerdr(env: NodeJS.ProcessEnv = process.env): boolean {
21 return env.HERDR_ENV === "1";
22}
23
24/** Pane id of the pane this agent runs in, when known. */
25export function currentPaneId(env: NodeJS.ProcessEnv = process.env): string | undefined {
26 return env.HERDR_PANE_ID || undefined;
27}
28
29// ============================================================================
30// Envelope parsing
31// ============================================================================
32
33export type Envelope = { ok: true; result: any } | { ok: false; error: string };
34
35export function parseEnvelope(stdout: string): Envelope {
36 const text = stdout.trim();
37 if (!text) return { ok: false, error: "empty response from herdr" };
38 let data: any;
39 try {
40 data = JSON.parse(text);
41 } catch {
42 return { ok: false, error: `unparsable herdr response: ${text.slice(0, 200)}` };
43 }
44 if (data?.error) {
45 const err = data.error;
46 return { ok: false, error: err.message ?? JSON.stringify(err) };
47 }
48 if (data?.result === undefined) return { ok: false, error: "herdr response has no result" };
49 return { ok: true, result: data.result };
50}
51
52/** Turn a non-zero herdr exit into a readable message (errors arrive on stderr). */export function errorFromFailure(code: number, stderr: string): string {
53 const text = stderr.trim();
54 if (text) {
55 try {
56 const data = JSON.parse(text);
57 if (data?.error?.message) return data.error.message;
58 } catch {
59 // not an envelope; fall through to the raw text
60 }
61 return text;
62 }
63 return `herdr exited with ${code}`;
64}
65
66/**
67 * Interpret a completed `herdr` invocation.
68 *
69 * Herdr answers on stdout with a JSON envelope, except `pane read` (raw
70 * terminal text) and the action commands (`pane run`), which succeed silently.
71 * Errors arrive on stderr as an envelope with a non-zero exit code.
72 */
73export function parseResponse(
74 code: number,
75 stdout: string,
76 stderr: string,
77 raw = false,
78): Envelope {
79 if (code !== 0) return { ok: false, error: errorFromFailure(code, stderr) };
80 if (raw) return { ok: true, result: stdout };
81 if (!stdout.trim()) return { ok: true, result: {} };
82 return parseEnvelope(stdout);
83}
84
85function status(value: unknown): AgentStatus {
86 switch (value) {
87 case "idle":
88 case "working":
89 case "blocked":
90 case "done":
91 return value;
92 default:
93 return "unknown";
94 }
95}
96
97export function parseAgents(result: any): HerdrAgent[] {
98 const agents = result?.agents;
99 if (!Array.isArray(agents)) return [];
100 return agents.map((a: any) => ({
101 agent: a.agent ?? "",
102 status: status(a.agent_status),
103 paneId: a.pane_id ?? "",
104 tabId: a.tab_id ?? "",
105 workspaceId: a.workspace_id ?? "",
106 cwd: a.foreground_cwd ?? a.cwd ?? "",
107 focused: a.focused === true,
108 title: a.terminal_title_stripped ?? a.terminal_title ?? "",
109 sessionPath: a.agent_session?.kind === "path" ? (a.agent_session.value ?? "") : "",
110 }));
111}
112
113export function parsePane(pane: any): HerdrPane {
114 return {
115 paneId: pane?.pane_id ?? "",
116 tabId: pane?.tab_id ?? "",
117 workspaceId: pane?.workspace_id ?? "",
118 cwd: pane?.foreground_cwd ?? pane?.cwd ?? "",
119 focused: pane?.focused === true,
120 agent: pane?.agent ?? "",
121 status: status(pane?.agent_status),
122 title: pane?.terminal_title_stripped ?? pane?.terminal_title ?? "",
123 };
124}
125
126export function parseWorkspaces(result: any): HerdrWorkspace[] {
127 const workspaces = result?.workspaces;
128 if (!Array.isArray(workspaces)) return [];
129 return workspaces.map((w: any) => ({
130 workspaceId: w.workspace_id ?? "",
131 label: w.label ?? "",
132 number: w.number ?? 0,
133 status: status(w.agent_status),
134 focused: w.focused === true,
135 paneCount: w.pane_count ?? 0,
136 tabCount: w.tab_count ?? 0,
137 }));
138}
139
140/** Pull a pane id out of any creation response (workspace/tab/pane/worktree). */
141export function extractPaneId(result: any): string | undefined {
142 return (
143 result?.pane?.pane_id ??
144 result?.root_pane?.pane_id ??
145 result?.pane_id ??
146 undefined
147 );
148}
149
150export function extractWorkspaceId(result: any): string | undefined {
151 return (
152 result?.workspace?.workspace_id ??
153 result?.root_pane?.workspace_id ??
154 result?.pane?.workspace_id ??
155 result?.workspace_id ??
156 undefined
157 );
158}
159
160/** One-line summary of a creation/action response, with the ids that matter. */
161export function formatCreated(action: HerdrAction, result: any): string {
162 const paneId = extractPaneId(result);
163 const workspaceId = extractWorkspaceId(result);
164 if (!paneId && !workspaceId) return `${action}: ok`;
165 const parts = [`${action}: ok`];
166 if (paneId) parts.push(`pane ${paneId}`);
167 if (workspaceId) parts.push(`workspace ${workspaceId}`);
168 const label = result?.workspace?.label ?? result?.tab?.label;
169 if (label) parts.push(`label "${label}"`);
170 return parts.join(", ");
171}
172
173// ============================================================================
174// Argument building
175// ============================================================================
176
177export interface HerdrParams {
178 action: HerdrAction;
179 target?: string;
180 text?: string;
181 lines?: number;
182 source?: string;
183 until?: string[];
184 timeout?: number;
185 cwd?: string;
186 label?: string;
187 branch?: string;
188 base?: string;
189 direction?: string;
190 workspace?: string;
191 command?: string[];
192 kind?: string;
193 name?: string;
194 focus?: boolean;
195 body?: string;
196 env?: string[];
197}
198
199function optional(args: string[], flag: string, value: unknown) {
200 if (value === undefined || value === null || value === "") return;
201 args.push(flag, String(value));
202}
203
204function focusFlag(args: string[], focus: boolean | undefined) {
205 if (focus === true) args.push("--focus");
206 else if (focus === false) args.push("--no-focus");
207}
208
209function envFlags(args: string[], env: string[] | undefined) {
210 for (const pair of env ?? []) args.push("--env", pair);
211}
212
213/** Build the herdr CLI argv for an action. Throws on missing required params. */
214export function buildArgs(p: HerdrParams): string[] {
215 const need = (value: string | undefined, what: string): string => {
216 if (!value) throw new Error(`${p.action} requires "${what}"`);
217 return value;
218 };
219
220 switch (p.action) {
221 case "agent-list":
222 return ["agent", "list"];
223
224 case "workspace-list":
225 return ["workspace", "list"];
226
227 case "pane-current":
228 return ["pane", "current"];
229
230 case "agent-read": {
231 const args = ["pane", "read", need(p.target, "target")];
232 optional(args, "--lines", p.lines ?? 100);
233 optional(args, "--source", p.source);
234 return args;
235 }
236
237 case "agent-wait": {
238 const args = ["agent", "wait", need(p.target, "target")];
239 for (const state of p.until ?? []) args.push("--until", state);
240 optional(args, "--timeout", p.timeout);
241 return args;
242 }
243
244 case "agent-prompt": {
245 const args = ["agent", "prompt", need(p.target, "target"), need(p.text, "text")];
246 if (p.until?.length || p.timeout) {
247 args.push("--wait");
248 for (const state of p.until ?? []) args.push("--until", state);
249 optional(args, "--timeout", p.timeout);
250 }
251 return args;
252 }
253
254 case "workspace-create": {
255 const args = ["workspace", "create"];
256 optional(args, "--cwd", p.cwd);
257 optional(args, "--label", p.label);
258 envFlags(args, p.env);
259 focusFlag(args, p.focus);
260 return args;
261 }
262
263 case "tab-create": {
264 const args = ["tab", "create"];
265 optional(args, "--workspace", p.workspace);
266 optional(args, "--cwd", p.cwd);
267 optional(args, "--label", p.label);
268 envFlags(args, p.env);
269 focusFlag(args, p.focus);
270 return args;
271 }
272
273 case "pane-split": {
274 const args = ["pane", "split"];
275 if (p.target) args.push(p.target);
276 else args.push("--current");
277 optional(args, "--direction", p.direction ?? "right");
278 optional(args, "--cwd", p.cwd);
279 envFlags(args, p.env);
280 focusFlag(args, p.focus);
281 return args;
282 }
283
284 case "pane-run": {
285 const command = p.command ?? [];
286 if (command.length === 0) throw new Error("pane-run requires \"command\"");
287 return ["pane", "run", need(p.target, "target"), ...command];
288 }
289
290 case "agent-start": {
291 const args = ["agent", "start", p.name ?? p.kind ?? "agent"];
292 args.push("--kind", need(p.kind, "kind"));
293 args.push("--pane", need(p.target, "target"));
294 optional(args, "--timeout", p.timeout);
295 return args;
296 }
297
298 case "worktree-create": {
299 const args = ["worktree", "create", "--json"];
300 optional(args, "--branch", need(p.branch, "branch"));
301 optional(args, "--base", p.base);
302 optional(args, "--cwd", p.cwd);
303 optional(args, "--label", p.label);
304 focusFlag(args, p.focus);
305 return args;
306 }
307
308 case "notify": {
309 const args = ["notification", "show", need(p.text, "text")];
310 optional(args, "--body", p.body);
311 return args;
312 }
313
314 default:
315 throw new Error(`unknown action: ${(p as HerdrParams).action}`);
316 }
317}
318
319/** Actions that change the workspace and therefore need user approval. */
320const WRITE_ACTIONS = new Set<HerdrAction>([
321 "agent-prompt",
322 "workspace-create",
323 "tab-create",
324 "pane-split",
325 "pane-run",
326 "agent-start",
327 "worktree-create",
328]);
329
330export function isWriteAction(action: HerdrAction): boolean {
331 return WRITE_ACTIONS.has(action);
332}
333
334/** Actions whose stdout is a raw terminal dump instead of a JSON envelope. */
335export function isRawOutput(action: HerdrAction): boolean {
336 return action === "agent-read";
337}
338
339/** Human-readable summary shown in the approval dialog. */
340export function buildConfirmation(p: HerdrParams): string {
341 switch (p.action) {
342 case "agent-prompt":
343 return `Send a prompt to agent ${p.target}:\n\n"${truncate(p.text ?? "", 300)}"`;
344 case "workspace-create":
345 return `Create a new workspace${p.label ? ` "${p.label}"` : ""}${p.cwd ? ` in ${p.cwd}` : ""}.`;
346 case "tab-create":
347 return `Create a new tab${p.label ? ` "${p.label}"` : ""}${p.cwd ? ` in ${p.cwd}` : ""}.`;
348 case "pane-split":
349 return `Split pane ${p.target ?? "(current)"} to the ${p.direction ?? "right"}${p.cwd ? ` in ${p.cwd}` : ""}.`;
350 case "pane-run":
351 return `Run in pane ${p.target}:\n\n${(p.command ?? []).join(" ")}`;
352 case "agent-start":
353 return `Start ${p.kind} in pane ${p.target}.`;
354 case "worktree-create":
355 return `Create git worktree for branch "${p.branch}"${p.base ? ` from ${p.base}` : ""}${p.cwd ? ` (repo ${p.cwd})` : ""}.`;
356 default:
357 return `Run herdr ${p.action}.`;
358 }
359}
360
361// ============================================================================
362// Formatting
363// ============================================================================
364
365export function truncate(text: string, maxLength: number): string {
366 if (text.length <= maxLength) return text;
367 return text.slice(0, Math.max(0, maxLength - 3)) + "...";
368}
369
370export function statusIcon(state: AgentStatus): string {
371 switch (state) {
372 case "working":
373 return "⏳";
374 case "blocked":
375 return "⚠";
376 case "idle":
377 return "●";
378 case "done":
379 return "✓";
380 default:
381 return "·";
382 }
383}
384
385export function formatAgents(agents: HerdrAgent[]): string {
386 if (agents.length === 0) return "No agents running in Herdr.";
387 const lines = ["| | Pane | Agent | Status | Cwd |", "|---|------|-------|--------|-----|"];
388 for (const a of agents) {
389 const focus = a.focused ? "→" : "";
390 lines.push(
391 `| ${focus} | ${a.paneId} | ${a.agent || "?"} | ${statusIcon(a.status)} ${a.status} | ${truncate(a.cwd, 48)} |`,
392 );
393 }
394 return lines.join("\n");
395}
396
397export function formatWorkspaces(workspaces: HerdrWorkspace[]): string {
398 if (workspaces.length === 0) return "No workspaces.";
399 const lines = ["| | Id | Label | Status | Tabs | Panes |", "|---|---|-------|--------|------|-------|"];
400 for (const w of workspaces) {
401 lines.push(
402 `| ${w.focused ? "→" : ""} | ${w.workspaceId} | ${w.label} | ${statusIcon(w.status)} ${w.status} | ${w.tabCount} | ${w.paneCount} |`,
403 );
404 }
405 return lines.join("\n");
406}
407
408export function formatPane(pane: HerdrPane): string {
409 return [
410 `pane: ${pane.paneId}${pane.focused ? " (focused)" : ""}`,
411 `tab: ${pane.tabId}`,
412 `workspace: ${pane.workspaceId}`,
413 `cwd: ${pane.cwd}`,
414 pane.agent ? `agent: ${pane.agent} (${pane.status})` : undefined,
415 pane.title ? `title: ${pane.title}` : undefined,
416 ]
417 .filter(Boolean)
418 .join("\n");
419}
420
421// ============================================================================
422// Approval gate (serialized: parallel ui.select dialogs deadlock the TUI)
423// ============================================================================
424
425let approvalMutex: Promise<void> = Promise.resolve();
426
427export async function approvalGate(ctx: ExtensionContext, prompt: string): Promise<boolean> {
428 return await new Promise<boolean>((resolve) => {
429 approvalMutex = approvalMutex.then(async () => {
430 const choice = await ctx.ui.select(prompt, ["✓ Accept", "✗ Reject"]);
431 resolve(choice === "✓ Accept");
432 });
433 });
434}