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}