@muzi3/herdr 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,763 @@
1
+ /**
2
+ * Herdr pane / agent 工具:`herdr_layout`、`herdr_pane`、`herdr_agent`。
3
+ *
4
+ * Fork 自 `@ogulcancelik/pi-herdr` 0.4.0(MIT,Copyright (c) 2026 Can Celik)。
5
+ * 上游许可全文见同包根目录 `LICENSE.MIT`,来源与改动说明见 README。
6
+ *
7
+ * 相对上游的改动(3 行):`pane run` / `pane send-text` / `pane send-keys` 在
8
+ * herdr 0.9.0 上成功时退出码为 0 且 stdout 为空。上游用 execHerdrJson 会额外要求
9
+ * JSON envelope,于是每一次写操作都误报 "Expected JSON output from herdr ..."
10
+ * —— 即使命令早已成功下发到目标 pane。
11
+ *
12
+ * 这三处改用 execHerdr:它只校验退出码,并把 stderr/stdout 上的结构化 herdr 错误
13
+ * 经 parseHerdrError 抛出,所以真实失败照旧报错,只有那个不成立的“stdout 必须非空”
14
+ * 约束被去掉。`pane close` / `pane wait-output` / `agent send-keys` 确实返回 JSON
15
+ * envelope,保持 execHerdrJson。回归测试见 tests/pane-agent-tools.test.ts。
16
+ */
17
+
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "@earendil-works/pi-coding-agent";
20
+ import { StringEnum } from "@earendil-works/pi-ai";
21
+ import { Text } from "@earendil-works/pi-tui";
22
+ import { Type } from "typebox";
23
+
24
+ type AgentStatus = "idle" | "working" | "blocked" | "done" | "unknown";
25
+ type ReadSource = "visible" | "recent" | "recent-unwrapped" | "detection";
26
+ type WaitOutputSource = Exclude<ReadSource, "detection">;
27
+ type SplitDirection = "right" | "down";
28
+ type OutputFormat = "text" | "ansi";
29
+
30
+ interface WorkspaceInfo {
31
+ workspace_id: string;
32
+ label: string;
33
+ focused: boolean;
34
+ agent_status: AgentStatus;
35
+ }
36
+
37
+ interface TabInfo {
38
+ tab_id: string;
39
+ workspace_id: string;
40
+ label: string;
41
+ focused: boolean;
42
+ agent_status: AgentStatus;
43
+ }
44
+
45
+ interface PaneInfo {
46
+ pane_id: string;
47
+ workspace_id: string;
48
+ tab_id: string;
49
+ focused: boolean;
50
+ cwd?: string;
51
+ foreground_cwd?: string;
52
+ label?: string;
53
+ agent?: string;
54
+ agent_status: AgentStatus;
55
+ }
56
+
57
+ interface AgentInfo {
58
+ name?: string;
59
+ agent?: string;
60
+ display_agent?: string;
61
+ agent_status: AgentStatus;
62
+ workspace_id: string;
63
+ tab_id: string;
64
+ pane_id: string;
65
+ focused: boolean;
66
+ cwd?: string;
67
+ }
68
+
69
+ interface PaneLayoutRect {
70
+ x: number;
71
+ y: number;
72
+ width: number;
73
+ height: number;
74
+ }
75
+
76
+ interface PaneLayoutSnapshot {
77
+ workspace_id: string;
78
+ tab_id: string;
79
+ zoomed: boolean;
80
+ focused_pane_id: string;
81
+ area: PaneLayoutRect;
82
+ panes: Array<{ pane_id: string; focused: boolean; rect: PaneLayoutRect }>;
83
+ splits: Array<{ id: string; direction: SplitDirection; ratio: number; rect: PaneLayoutRect }>;
84
+ }
85
+
86
+ interface HerdrJsonEnvelope {
87
+ result?: unknown;
88
+ error?: {
89
+ code?: string;
90
+ message?: string;
91
+ };
92
+ }
93
+
94
+ const StatusEnum = StringEnum(["idle", "working", "blocked", "done", "unknown"] as const, {
95
+ description: "Agent lifecycle state",
96
+ });
97
+
98
+ const ReadSourceEnum = StringEnum(["visible", "recent", "recent-unwrapped", "detection"] as const, {
99
+ description: "Terminal snapshot source",
100
+ });
101
+
102
+ const OutputFormatEnum = StringEnum(["text", "ansi"] as const, {
103
+ description: "Output format; ansi preserves terminal styling",
104
+ });
105
+
106
+ const DirectionEnum = StringEnum(["right", "down"] as const, {
107
+ description: "Split direction. When omitted, the tool chooses from the source pane geometry.",
108
+ });
109
+
110
+ const AgentKindEnum = StringEnum(
111
+ [
112
+ "pi",
113
+ "claude",
114
+ "codex",
115
+ "gemini",
116
+ "cursor",
117
+ "devin",
118
+ "agy",
119
+ "cline",
120
+ "omp",
121
+ "mastracode",
122
+ "opencode",
123
+ "copilot",
124
+ "kimi",
125
+ "kiro",
126
+ "droid",
127
+ "amp",
128
+ "grok",
129
+ "hermes",
130
+ "kilo",
131
+ "qodercli",
132
+ "maki",
133
+ ] as const,
134
+ { description: "Supported coding agent kind and canonical executable" },
135
+ );
136
+
137
+ function parseHerdrError(output: string): string | null {
138
+ const trimmed = output.trim();
139
+ if (!trimmed) return null;
140
+ try {
141
+ const value = JSON.parse(trimmed) as HerdrJsonEnvelope;
142
+ return value.error?.message || value.error?.code || trimmed;
143
+ } catch {
144
+ return trimmed;
145
+ }
146
+ }
147
+
148
+ function isAbortError(error: unknown, signal?: AbortSignal): boolean {
149
+ return signal?.aborted === true || (error instanceof Error && error.message === "Aborted");
150
+ }
151
+
152
+ function formatOutput(output: string): string {
153
+ const truncation = truncateTail(output, {
154
+ maxLines: DEFAULT_MAX_LINES,
155
+ maxBytes: DEFAULT_MAX_BYTES,
156
+ });
157
+ if (!truncation.truncated) return truncation.content;
158
+ return `[Showing last ${truncation.outputLines} of ${truncation.totalLines} lines]\n${truncation.content}`;
159
+ }
160
+
161
+ function chooseSplitDirection(layout: PaneLayoutSnapshot, paneId: string): SplitDirection {
162
+ const pane = layout.panes.find((candidate) => candidate.pane_id === paneId);
163
+ if (!pane) return "right";
164
+ return pane.rect.width >= 80 && pane.rect.width >= pane.rect.height * 2 ? "right" : "down";
165
+ }
166
+
167
+ function statusDot(theme: any, status: AgentStatus): string {
168
+ switch (status) {
169
+ case "blocked":
170
+ return theme.fg("warning", "●");
171
+ case "working":
172
+ return theme.fg("accent", "●");
173
+ case "done":
174
+ return theme.fg("success", "●");
175
+ case "idle":
176
+ return theme.fg("muted", "○");
177
+ default:
178
+ return theme.fg("dim", "·");
179
+ }
180
+ }
181
+
182
+ function agentDisplayName(agent: AgentInfo): string {
183
+ return agent.name || agent.display_agent || agent.agent || agent.pane_id;
184
+ }
185
+
186
+ function summarizeAgent(agent: AgentInfo): string {
187
+ const cwd = agent.cwd ? ` ${agent.cwd}` : "";
188
+ return `${agentDisplayName(agent)}: [${agent.pane_id}] (${agent.agent_status}${agent.focused ? ", focused" : ""})${cwd}`;
189
+ }
190
+
191
+ function summarizePane(pane: PaneInfo, currentPaneId?: string): string {
192
+ const flags = [
193
+ pane.pane_id === currentPaneId ? "current" : pane.focused ? "focused" : null,
194
+ pane.agent,
195
+ pane.agent_status !== "unknown" ? pane.agent_status : null,
196
+ ]
197
+ .filter(Boolean)
198
+ .join(", ");
199
+ const cwd = pane.foreground_cwd || pane.cwd;
200
+ return `${pane.label || pane.pane_id}: [${pane.pane_id}]${flags ? ` (${flags})` : ""}${cwd ? ` ${cwd}` : ""}`;
201
+ }
202
+
203
+ function summarizeTab(tab: TabInfo): string {
204
+ const flags = [tab.focused ? "focused" : null, tab.agent_status !== "unknown" ? tab.agent_status : null]
205
+ .filter(Boolean)
206
+ .join(", ");
207
+ return `${tab.label}: [${tab.tab_id}]${flags ? ` (${flags})` : ""}`;
208
+ }
209
+
210
+ function summarizeWorkspace(workspace: WorkspaceInfo): string {
211
+ const flags = [
212
+ workspace.focused ? "focused" : null,
213
+ workspace.agent_status !== "unknown" ? workspace.agent_status : null,
214
+ ]
215
+ .filter(Boolean)
216
+ .join(", ");
217
+ return `${workspace.label}: [${workspace.workspace_id}]${flags ? ` (${flags})` : ""}`;
218
+ }
219
+
220
+ function renderToolCall(tool: string, args: Record<string, any>, theme: any, context: any) {
221
+ const component = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
222
+ let text = theme.fg("toolTitle", theme.bold(`${tool} `));
223
+ text += theme.fg("accent", args.action || "?");
224
+ const target = args.target || args.pane || args.tab || args.workspace;
225
+ if (target) text += theme.fg("muted", ` ${target}`);
226
+ if (args.name) text += theme.fg("muted", ` ${args.name}`);
227
+ if (args.kind) text += theme.fg("dim", ` › ${args.kind}`);
228
+ if (args.direction) text += theme.fg("dim", ` › ${args.direction}`);
229
+ if (args.command) text += theme.fg("dim", ` › ${args.command}`);
230
+ if (args.prompt) text += theme.fg("dim", ` › ${args.prompt}`);
231
+ if (args.match) text += theme.fg("dim", ` › ${args.match}`);
232
+ component.setText(text);
233
+ return component;
234
+ }
235
+
236
+ function renderToolResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any) {
237
+ if (options.isPartial) return new Text(theme.fg("warning", "◌ waiting"), 0, 0);
238
+ const details = result.details as Record<string, any> | undefined;
239
+ const content = result.content?.[0];
240
+ const rawText = content?.type === "text" ? content.text : "";
241
+ if (!details) return new Text(rawText, 0, 0);
242
+
243
+ if (details.agent) {
244
+ const agent = details.agent as AgentInfo;
245
+ return new Text(
246
+ `${statusDot(theme, agent.agent_status)} ${theme.fg("accent", agentDisplayName(agent))} ${theme.fg("dim", agent.agent_status)}`,
247
+ 0,
248
+ 0,
249
+ );
250
+ }
251
+ if (Array.isArray(details.agents)) {
252
+ const agents = details.agents as AgentInfo[];
253
+ return new Text(
254
+ agents.length
255
+ ? agents
256
+ .map(
257
+ (agent) =>
258
+ `${statusDot(theme, agent.agent_status)} ${theme.fg(agent.focused ? "accent" : "muted", agentDisplayName(agent))} ${theme.fg("dim", agent.agent_status)}`,
259
+ )
260
+ .join("\n")
261
+ : theme.fg("dim", "no agents"),
262
+ 0,
263
+ 0,
264
+ );
265
+ }
266
+ if (details.read) {
267
+ let text = theme.fg("accent", `▤ ${details.target || details.pane}`);
268
+ if (options.expanded && rawText) text += `\n${rawText.split("\n").slice(0, 40).map((line: string) => theme.fg("dim", line)).join("\n")}`;
269
+ return new Text(text, 0, 0);
270
+ }
271
+ return new Text(theme.fg("success", `✓ ${details.action || "done"}`), 0, 0);
272
+ }
273
+
274
+ export default function (pi: ExtensionAPI) {
275
+ if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_PANE_ID) return;
276
+
277
+ async function execHerdr(args: string[], signal?: AbortSignal) {
278
+ const result = await pi.exec("herdr", args, { signal });
279
+ if (signal?.aborted || result.killed) throw new Error("Aborted");
280
+ if (result.code !== 0) {
281
+ const message =
282
+ parseHerdrError(result.stderr) ||
283
+ parseHerdrError(result.stdout) ||
284
+ `herdr ${args.join(" ")} failed with exit code ${result.code}`;
285
+ throw new Error(message);
286
+ }
287
+ return result;
288
+ }
289
+
290
+ async function execHerdrJson<T>(args: string[], signal?: AbortSignal): Promise<T> {
291
+ const result = await execHerdr(args, signal);
292
+ const stdout = result.stdout.trim();
293
+ if (!stdout) throw new Error(`Expected JSON output from herdr ${args.join(" ")}`);
294
+ let value: HerdrJsonEnvelope;
295
+ try {
296
+ value = JSON.parse(stdout) as HerdrJsonEnvelope;
297
+ } catch {
298
+ throw new Error(`Failed to parse JSON from herdr ${args.join(" ")}`);
299
+ }
300
+ if (value.error) throw new Error(value.error.message || value.error.code || `herdr ${args.join(" ")} failed`);
301
+ return value as T;
302
+ }
303
+
304
+ async function execHerdrText(args: string[], signal?: AbortSignal): Promise<string> {
305
+ return (await execHerdr(args, signal)).stdout;
306
+ }
307
+
308
+ async function getCurrentPane(signal?: AbortSignal): Promise<PaneInfo> {
309
+ const response = await execHerdrJson<{ result: { pane: PaneInfo } }>(["pane", "current", "--current"], signal);
310
+ return response.result.pane;
311
+ }
312
+
313
+ async function getPane(paneId: string, signal?: AbortSignal): Promise<PaneInfo> {
314
+ const response = await execHerdrJson<{ result: { pane: PaneInfo } }>(["pane", "get", paneId], signal);
315
+ return response.result.pane;
316
+ }
317
+
318
+ async function getPaneLayout(paneId: string, signal?: AbortSignal): Promise<PaneLayoutSnapshot> {
319
+ const response = await execHerdrJson<{ result: { layout: PaneLayoutSnapshot } }>(
320
+ ["pane", "layout", "--pane", paneId],
321
+ signal,
322
+ );
323
+ return response.result.layout;
324
+ }
325
+
326
+ pi.registerTool({
327
+ name: "herdr_layout",
328
+ label: "Herdr Layout",
329
+ description:
330
+ "Create and inspect Herdr terminal topology. Workspaces contain tabs; tabs contain panes. Creating a workspace or tab also creates a root pane, while splitting creates another pane. Layout actions never start an agent or ordinary command. Read pane IDs from results and pass them to herdr_pane or herdr_agent. Creation defaults to the caller's cwd and preserves UI focus. pane_split defaults to the caller's pane and chooses right or down from its geometry.",
331
+ promptSnippet: "Inspect or create Herdr workspaces, tabs, and pane topology",
332
+ promptGuidelines: [
333
+ "Use herdr_layout, herdr_pane, and herdr_agent only when the user explicitly mentions Herdr or asks to inspect or control Herdr.",
334
+ "Use herdr_layout to create terminal topology before starting a process or agent. Default to a sibling pane in the caller's current tab and cwd; create a tab or workspace only when requested.",
335
+ "Read opaque workspace, tab, and pane IDs from herdr_layout results instead of constructing them, and preserve UI focus unless the user asks to switch context.",
336
+ ],
337
+ parameters: Type.Object({
338
+ action: StringEnum(
339
+ [
340
+ "current",
341
+ "workspace_list",
342
+ "workspace_create",
343
+ "workspace_focus",
344
+ "tab_list",
345
+ "tab_create",
346
+ "tab_focus",
347
+ "pane_list",
348
+ "pane_layout",
349
+ "pane_split",
350
+ ] as const,
351
+ { description: "Layout action" },
352
+ ),
353
+ workspace: Type.Optional(Type.String({ description: "Opaque workspace ID" })),
354
+ tab: Type.Optional(Type.String({ description: "Opaque tab ID" })),
355
+ pane: Type.Optional(
356
+ Type.String({ description: "Opaque source pane ID. Omit for current, pane_layout, or pane_split to use the caller's pane." }),
357
+ ),
358
+ label: Type.Optional(Type.String({ description: "Label for a new workspace or tab" })),
359
+ direction: Type.Optional(DirectionEnum),
360
+ cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the caller pane's foreground cwd." })),
361
+ focus: Type.Optional(Type.Boolean({ description: "Change UI focus after creation. Defaults to false." })),
362
+ }),
363
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
364
+ switch (params.action) {
365
+ case "current": {
366
+ const pane = await getCurrentPane(signal);
367
+ return {
368
+ content: [{ type: "text", text: summarizePane(pane, pane.pane_id) }],
369
+ details: { action: "current", pane },
370
+ };
371
+ }
372
+ case "workspace_list": {
373
+ const response = await execHerdrJson<{ result: { workspaces: WorkspaceInfo[] } }>(
374
+ ["workspace", "list"],
375
+ signal,
376
+ );
377
+ const workspaces = response.result.workspaces || [];
378
+ return {
379
+ content: [{ type: "text", text: workspaces.length ? workspaces.map(summarizeWorkspace).join("\n") : "No workspaces." }],
380
+ details: { action: "workspace_list", workspaces },
381
+ };
382
+ }
383
+ case "workspace_create": {
384
+ const current = await getCurrentPane(signal);
385
+ const args = ["workspace", "create", "--cwd", params.cwd || current.foreground_cwd || current.cwd || process.cwd()];
386
+ if (params.label) args.push("--label", params.label);
387
+ args.push(params.focus === true ? "--focus" : "--no-focus");
388
+ const response = await execHerdrJson<{
389
+ result: { workspace: WorkspaceInfo; tab: TabInfo; root_pane: PaneInfo };
390
+ }>(args, signal);
391
+ const { workspace, tab, root_pane: rootPane } = response.result;
392
+ return {
393
+ content: [{ type: "text", text: `Created workspace ${workspace.workspace_id}, tab ${tab.tab_id}, root pane ${rootPane.pane_id}` }],
394
+ details: { action: "workspace_create", workspace, tab, pane: rootPane },
395
+ };
396
+ }
397
+ case "workspace_focus": {
398
+ if (!params.workspace) throw new Error("'workspace' is required for workspace_focus");
399
+ const response = await execHerdrJson<{ result: { workspace: WorkspaceInfo } }>(
400
+ ["workspace", "focus", params.workspace],
401
+ signal,
402
+ );
403
+ return {
404
+ content: [{ type: "text", text: `Focused workspace ${response.result.workspace.workspace_id}` }],
405
+ details: { action: "workspace_focus", workspace: response.result.workspace },
406
+ };
407
+ }
408
+ case "tab_list": {
409
+ const args = ["tab", "list"];
410
+ if (params.workspace) args.push("--workspace", params.workspace);
411
+ const response = await execHerdrJson<{ result: { tabs: TabInfo[] } }>(args, signal);
412
+ const tabs = response.result.tabs || [];
413
+ return {
414
+ content: [{ type: "text", text: tabs.length ? tabs.map(summarizeTab).join("\n") : "No tabs." }],
415
+ details: { action: "tab_list", tabs },
416
+ };
417
+ }
418
+ case "tab_create": {
419
+ const current = await getCurrentPane(signal);
420
+ const args = ["tab", "create", "--workspace", params.workspace || current.workspace_id];
421
+ args.push("--cwd", params.cwd || current.foreground_cwd || current.cwd || process.cwd());
422
+ if (params.label) args.push("--label", params.label);
423
+ args.push(params.focus === true ? "--focus" : "--no-focus");
424
+ const response = await execHerdrJson<{ result: { tab: TabInfo; root_pane: PaneInfo } }>(args, signal);
425
+ const { tab, root_pane: rootPane } = response.result;
426
+ return {
427
+ content: [{ type: "text", text: `Created tab ${tab.tab_id}, root pane ${rootPane.pane_id}` }],
428
+ details: { action: "tab_create", tab, pane: rootPane },
429
+ };
430
+ }
431
+ case "tab_focus": {
432
+ if (!params.tab) throw new Error("'tab' is required for tab_focus");
433
+ const response = await execHerdrJson<{ result: { tab: TabInfo } }>(["tab", "focus", params.tab], signal);
434
+ return {
435
+ content: [{ type: "text", text: `Focused tab ${response.result.tab.tab_id}` }],
436
+ details: { action: "tab_focus", tab: response.result.tab },
437
+ };
438
+ }
439
+ case "pane_list": {
440
+ const current = await getCurrentPane(signal);
441
+ const workspaceId = params.workspace || current.workspace_id;
442
+ const response = await execHerdrJson<{ result: { panes: PaneInfo[] } }>(
443
+ ["pane", "list", "--workspace", workspaceId],
444
+ signal,
445
+ );
446
+ const panes = response.result.panes || [];
447
+ return {
448
+ content: [{ type: "text", text: panes.length ? panes.map((pane) => summarizePane(pane, current.pane_id)).join("\n") : "No panes." }],
449
+ details: { action: "pane_list", panes, workspaceId },
450
+ };
451
+ }
452
+ case "pane_layout": {
453
+ const paneId = params.pane || (await getCurrentPane(signal)).pane_id;
454
+ const layout = await getPaneLayout(paneId, signal);
455
+ return {
456
+ content: [{ type: "text", text: JSON.stringify(layout, null, 2) }],
457
+ details: { action: "pane_layout", layout },
458
+ };
459
+ }
460
+ case "pane_split": {
461
+ const current = await getCurrentPane(signal);
462
+ const source = params.pane ? await getPane(params.pane, signal) : current;
463
+ const direction = params.direction || chooseSplitDirection(await getPaneLayout(source.pane_id, signal), source.pane_id);
464
+ const cwd = params.cwd || source.foreground_cwd || source.cwd || current.foreground_cwd || current.cwd || process.cwd();
465
+ const args = ["pane", "split", source.pane_id, "--direction", direction, "--cwd", cwd];
466
+ args.push(params.focus === true ? "--focus" : "--no-focus");
467
+ const response = await execHerdrJson<{ result: { pane: PaneInfo } }>(args, signal);
468
+ const pane = response.result.pane;
469
+ return {
470
+ content: [{ type: "text", text: `Created pane ${pane.pane_id} by splitting ${source.pane_id} ${direction}` }],
471
+ details: { action: "pane_split", pane, sourcePaneId: source.pane_id, direction },
472
+ };
473
+ }
474
+ }
475
+ },
476
+ renderCall(args, theme, context) {
477
+ return renderToolCall("herdr_layout", args, theme, context);
478
+ },
479
+ renderResult(result, options, theme) {
480
+ return renderToolResult(result, options, theme);
481
+ },
482
+ });
483
+
484
+ pi.registerTool({
485
+ name: "herdr_pane",
486
+ label: "Herdr Pane",
487
+ description:
488
+ "Control a raw Herdr terminal pane. Use for shells, tests, servers, builds, logs, and other ordinary processes: run a command, read output, wait for matching output, send literal text or terminal keys, inspect, or close. Pane actions target opaque pane IDs and do not validate agent identity or interpret agent lifecycle. Use herdr_agent instead when controlling a recognized coding agent. Read output is truncated to 2000 lines or 50KB.",
489
+ promptSnippet: "Run and inspect ordinary commands in Herdr terminal panes",
490
+ promptGuidelines: [
491
+ "Use herdr_pane for ordinary commands and raw terminal control; use herdr_agent for coding-agent prompts, lifecycle waits, reads, and interactive keys.",
492
+ "Use herdr_pane wait_output for tests, servers, builds, and watchers. It searches existing output immediately; use recent-unwrapped for logs and transcripts.",
493
+ "Do not close a Herdr pane you did not create unless the user explicitly asks. herdr_pane always refuses to close the pane running the current pi process.",
494
+ ],
495
+ parameters: Type.Object({
496
+ action: StringEnum(["get", "run", "read", "wait_output", "send_text", "send_keys", "close"] as const, {
497
+ description: "Raw pane action",
498
+ }),
499
+ pane: Type.String({ description: "Opaque pane ID returned by herdr_layout" }),
500
+ command: Type.Optional(Type.String({ description: "Shell command to submit atomically with Enter for run" })),
501
+ text: Type.Optional(Type.String({ description: "Literal text to send without Enter for send_text" })),
502
+ keys: Type.Optional(
503
+ Type.Array(Type.String(), { description: "Logical terminal keys for send_keys, such as esc, enter, up, or ctrl+c" }),
504
+ ),
505
+ match: Type.Optional(Type.String({ description: "Literal substring or Rust regular expression for wait_output" })),
506
+ regex: Type.Optional(Type.Boolean({ description: "Treat match as a Rust regular expression" })),
507
+ source: Type.Optional(ReadSourceEnum),
508
+ lines: Type.Optional(Type.Integer({ minimum: 1, description: "Rendered terminal rows to read or search" })),
509
+ format: Type.Optional(OutputFormatEnum),
510
+ raw: Type.Optional(Type.Boolean({ description: "Keep ANSI escapes while matching wait_output" })),
511
+ timeout: Type.Optional(Type.Integer({ minimum: 1, description: "Wait timeout in milliseconds; omitted means indefinite" })),
512
+ }),
513
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
514
+ switch (params.action) {
515
+ case "get": {
516
+ const pane = await getPane(params.pane, signal);
517
+ return {
518
+ content: [{ type: "text", text: summarizePane(pane) }],
519
+ details: { action: "get", pane },
520
+ };
521
+ }
522
+ case "run": {
523
+ if (!params.command) throw new Error("'command' is required for run");
524
+ // pane run/send-text/send-keys report success with empty stdout, so only the exit code is meaningful.
525
+ await execHerdr(["pane", "run", params.pane, params.command], signal);
526
+ return {
527
+ content: [{ type: "text", text: `Submitted command to pane ${params.pane}` }],
528
+ details: { action: "run", pane: params.pane, command: params.command },
529
+ };
530
+ }
531
+ case "read": {
532
+ const args = ["pane", "read", params.pane, "--source", params.source || "recent-unwrapped"];
533
+ if (params.lines != null) args.push("--lines", String(params.lines));
534
+ if (params.format) args.push("--format", params.format);
535
+ const output = await execHerdrText(args, signal);
536
+ return {
537
+ content: [{ type: "text", text: formatOutput(output) }],
538
+ details: { action: "read", pane: params.pane, read: true, source: params.source || "recent-unwrapped" },
539
+ };
540
+ }
541
+ case "wait_output": {
542
+ if (!params.match) throw new Error("'match' is required for wait_output");
543
+ if (params.source === "detection") throw new Error("wait_output does not support the detection source; use read");
544
+ const startedAt = Date.now();
545
+ onUpdate?.({
546
+ content: [{ type: "text", text: `Waiting for output in ${params.pane}...` }],
547
+ details: { action: "wait_output", pane: params.pane, waiting: true },
548
+ });
549
+ const args = ["pane", "wait-output", params.pane, params.regex ? "--regex" : "--match", params.match];
550
+ if (params.source) args.push("--source", params.source as WaitOutputSource);
551
+ if (params.lines != null) args.push("--lines", String(params.lines));
552
+ if (params.timeout != null) args.push("--timeout", String(params.timeout));
553
+ if (params.raw) args.push("--raw");
554
+ const response = await execHerdrJson<{
555
+ result: { pane_id: string; matched_line: string; read?: { text?: string } };
556
+ }>(args, signal);
557
+ const matched = response.result;
558
+ const output = matched.read?.text || matched.matched_line;
559
+ return {
560
+ content: [{ type: "text", text: `Matched: ${matched.matched_line}\n\n${formatOutput(output)}` }],
561
+ details: {
562
+ action: "wait_output",
563
+ pane: params.pane,
564
+ matchedLine: matched.matched_line,
565
+ elapsedMs: Date.now() - startedAt,
566
+ },
567
+ };
568
+ }
569
+ case "send_text": {
570
+ if (!params.text) throw new Error("'text' is required for send_text");
571
+ await execHerdr(["pane", "send-text", params.pane, params.text], signal);
572
+ return {
573
+ content: [{ type: "text", text: `Sent literal text to pane ${params.pane}` }],
574
+ details: { action: "send_text", pane: params.pane },
575
+ };
576
+ }
577
+ case "send_keys": {
578
+ if (!params.keys?.length) throw new Error("'keys' is required for send_keys");
579
+ await execHerdr(["pane", "send-keys", params.pane, ...params.keys], signal);
580
+ return {
581
+ content: [{ type: "text", text: `Sent ${params.keys.join(" ")} to pane ${params.pane}` }],
582
+ details: { action: "send_keys", pane: params.pane, keys: params.keys },
583
+ };
584
+ }
585
+ case "close": {
586
+ const current = await getCurrentPane(signal);
587
+ if (params.pane === current.pane_id) throw new Error("Refusing to close the pane pi is running in.");
588
+ await execHerdrJson(["pane", "close", params.pane], signal);
589
+ return {
590
+ content: [{ type: "text", text: `Closed pane ${params.pane}` }],
591
+ details: { action: "close", pane: params.pane },
592
+ };
593
+ }
594
+ }
595
+ },
596
+ renderCall(args, theme, context) {
597
+ return renderToolCall("herdr_pane", args, theme, context);
598
+ },
599
+ renderResult(result, options, theme) {
600
+ return renderToolResult(result, options, theme);
601
+ },
602
+ });
603
+
604
+ pi.registerTool({
605
+ name: "herdr_agent",
606
+ label: "Herdr Agent",
607
+ description:
608
+ "Control a recognized coding agent occupying an existing Herdr pane. Starting requires an available interactive shell pane created through herdr_layout and never creates or changes layout. Agent targets are unique live names or the pane ID currently hosting the agent, never terminal IDs or bare kind labels. Use prompt, wait, read, and send_keys instead of raw pane input. Lifecycle states are working, blocked, done, idle, and unknown; prompt and wait default to the first settled idle, done, or blocked state. Read output is truncated to 2000 lines or 50KB.",
609
+ promptSnippet: "Start, prompt, wait for, read, and interact with coding agents in Herdr",
610
+ promptGuidelines: [
611
+ "Use herdr_agent for recognized coding agents. Use herdr_layout to create an available shell pane first; herdr_agent start never creates or moves terminal layout.",
612
+ "For normal helper work, use herdr_layout pane_split, then herdr_agent start, herdr_agent prompt with wait enabled, and herdr_agent read. Use herdr_pane only for ordinary processes or intentional raw terminal control.",
613
+ "Treat herdr_agent idle and done as ready states, blocked as requiring inspection or input, and unknown as uncertain rather than completed. CLI reads do not mark done work as seen.",
614
+ "If herdr_agent read cannot recover a full alternate-screen response after increasing lines, ask the agent to write its complete response to a temporary Markdown file and return the path, then read that file directly.",
615
+ ],
616
+ parameters: Type.Object({
617
+ action: StringEnum(["list", "get", "start", "prompt", "wait", "read", "send_keys", "focus", "rename"] as const, {
618
+ description: "Agent lifecycle action",
619
+ }),
620
+ target: Type.Optional(Type.String({ description: "Unique live agent name or pane ID currently hosting the agent" })),
621
+ pane: Type.Optional(Type.String({ description: "Existing available shell pane ID for start" })),
622
+ name: Type.Optional(
623
+ Type.String({
624
+ pattern: "^[a-z][a-z0-9_-]{0,31}$",
625
+ description: "Unique agent name for start or replacement name for rename",
626
+ }),
627
+ ),
628
+ kind: Type.Optional(AgentKindEnum),
629
+ agentArgs: Type.Optional(Type.Array(Type.String(), { description: "Native agent arguments passed unchanged after -- for start" })),
630
+ prompt: Type.Optional(Type.String({ description: "Prompt text submitted atomically with Enter" })),
631
+ wait: Type.Optional(Type.Boolean({ description: "Wait for lifecycle settlement after prompt. Defaults to true." })),
632
+ until: Type.Optional(Type.Array(StatusEnum, { description: "Accepted lifecycle states for prompt with wait or wait; defaults to idle, done, or blocked" })),
633
+ timeout: Type.Optional(Type.Integer({ minimum: 1, description: "Timeout in milliseconds; omitted means indefinite" })),
634
+ source: Type.Optional(ReadSourceEnum),
635
+ lines: Type.Optional(Type.Integer({ minimum: 1, description: "Rendered terminal rows to read" })),
636
+ format: Type.Optional(OutputFormatEnum),
637
+ keys: Type.Optional(Type.Array(Type.String(), { description: "Logical UI keys such as esc, enter, up, or ctrl+c" })),
638
+ clearName: Type.Optional(Type.Boolean({ description: "Clear the current agent name for rename" })),
639
+ }),
640
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
641
+ switch (params.action) {
642
+ case "list": {
643
+ const response = await execHerdrJson<{ result: { agents: AgentInfo[] } }>(["agent", "list"], signal);
644
+ const agents = response.result.agents || [];
645
+ return {
646
+ content: [{ type: "text", text: agents.length ? agents.map(summarizeAgent).join("\n") : "No agents." }],
647
+ details: { action: "list", agents },
648
+ };
649
+ }
650
+ case "get": {
651
+ if (!params.target) throw new Error("'target' is required for get");
652
+ const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(["agent", "get", params.target], signal);
653
+ return {
654
+ content: [{ type: "text", text: summarizeAgent(response.result.agent) }],
655
+ details: { action: "get", agent: response.result.agent },
656
+ };
657
+ }
658
+ case "start": {
659
+ if (!params.name) throw new Error("'name' is required for start");
660
+ if (!params.kind) throw new Error("'kind' is required for start");
661
+ if (!params.pane) throw new Error("'pane' is required for start");
662
+ if (params.timeout != null && (params.timeout <= 3000 || params.timeout > 300000)) {
663
+ throw new Error("start timeout must be greater than 3000ms and at most 300000ms");
664
+ }
665
+ const args = ["agent", "start", params.name, "--kind", params.kind, "--pane", params.pane];
666
+ if (params.timeout != null) args.push("--timeout", String(params.timeout));
667
+ if (params.agentArgs?.length) args.push("--", ...params.agentArgs);
668
+ onUpdate?.({
669
+ content: [{ type: "text", text: `Starting ${params.kind} as ${params.name} in ${params.pane}...` }],
670
+ details: { action: "start", waiting: true },
671
+ });
672
+ const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
673
+ return {
674
+ content: [{ type: "text", text: `Started ${summarizeAgent(response.result.agent)}` }],
675
+ details: { action: "start", agent: response.result.agent },
676
+ };
677
+ }
678
+ case "prompt": {
679
+ if (!params.target) throw new Error("'target' is required for prompt");
680
+ if (!params.prompt) throw new Error("'prompt' is required for prompt");
681
+ const shouldWait = params.wait !== false;
682
+ if (!shouldWait && params.until?.length) throw new Error("'until' requires wait for prompt");
683
+ if (!shouldWait && params.timeout != null) throw new Error("'timeout' requires wait for prompt");
684
+ const args = ["agent", "prompt", params.target, params.prompt];
685
+ if (shouldWait) args.push("--wait");
686
+ for (const status of params.until || []) args.push("--until", status);
687
+ if (params.timeout != null) args.push("--timeout", String(params.timeout));
688
+ if (shouldWait) {
689
+ onUpdate?.({
690
+ content: [{ type: "text", text: `Prompted ${params.target}; waiting for lifecycle settlement...` }],
691
+ details: { action: "prompt", target: params.target, waiting: true },
692
+ });
693
+ }
694
+ const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
695
+ return {
696
+ content: [{ type: "text", text: `${shouldWait ? "Prompt settled" : "Prompt submitted"}: ${summarizeAgent(response.result.agent)}` }],
697
+ details: { action: "prompt", agent: response.result.agent },
698
+ };
699
+ }
700
+ case "wait": {
701
+ if (!params.target) throw new Error("'target' is required for wait");
702
+ const args = ["agent", "wait", params.target];
703
+ for (const status of params.until || []) args.push("--until", status);
704
+ if (params.timeout != null) args.push("--timeout", String(params.timeout));
705
+ onUpdate?.({
706
+ content: [{ type: "text", text: `Waiting for agent ${params.target}...` }],
707
+ details: { action: "wait", target: params.target, waiting: true },
708
+ });
709
+ const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
710
+ return {
711
+ content: [{ type: "text", text: `Agent settled: ${summarizeAgent(response.result.agent)}` }],
712
+ details: { action: "wait", agent: response.result.agent },
713
+ };
714
+ }
715
+ case "read": {
716
+ if (!params.target) throw new Error("'target' is required for read");
717
+ const args = ["agent", "read", params.target, "--source", params.source || "recent-unwrapped"];
718
+ if (params.lines != null) args.push("--lines", String(params.lines));
719
+ if (params.format) args.push("--format", params.format as OutputFormat);
720
+ const output = await execHerdrText(args, signal);
721
+ return {
722
+ content: [{ type: "text", text: formatOutput(output) }],
723
+ details: { action: "read", target: params.target, read: true, source: params.source || "recent-unwrapped" },
724
+ };
725
+ }
726
+ case "send_keys": {
727
+ if (!params.target) throw new Error("'target' is required for send_keys");
728
+ if (!params.keys?.length) throw new Error("'keys' is required for send_keys");
729
+ await execHerdrJson(["agent", "send-keys", params.target, ...params.keys], signal);
730
+ return {
731
+ content: [{ type: "text", text: `Sent ${params.keys.join(" ")} to ${params.target}` }],
732
+ details: { action: "send_keys", target: params.target, keys: params.keys },
733
+ };
734
+ }
735
+ case "focus": {
736
+ if (!params.target) throw new Error("'target' is required for focus");
737
+ const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(["agent", "focus", params.target], signal);
738
+ return {
739
+ content: [{ type: "text", text: `Focused ${agentDisplayName(response.result.agent)}` }],
740
+ details: { action: "focus", agent: response.result.agent },
741
+ };
742
+ }
743
+ case "rename": {
744
+ if (!params.target) throw new Error("'target' is required for rename");
745
+ if (!params.clearName && !params.name) throw new Error("'name' or 'clearName' is required for rename");
746
+ const args = ["agent", "rename", params.target];
747
+ args.push(params.clearName ? "--clear" : params.name!);
748
+ const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
749
+ return {
750
+ content: [{ type: "text", text: params.clearName ? `Cleared agent name for ${params.target}` : `Renamed agent to ${params.name}` }],
751
+ details: { action: "rename", agent: response.result.agent },
752
+ };
753
+ }
754
+ }
755
+ },
756
+ renderCall(args, theme, context) {
757
+ return renderToolCall("herdr_agent", args, theme, context);
758
+ },
759
+ renderResult(result, options, theme) {
760
+ return renderToolResult(result, options, theme);
761
+ },
762
+ });
763
+ }