@rahularya01/pi-essentials 0.1.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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/examples/mcp.json +30 -0
  4. package/examples/pi-essentials.json +32 -0
  5. package/examples/pi-settings.json +5 -0
  6. package/package.json +88 -0
  7. package/skills/pi-essentials/SKILL.md +50 -0
  8. package/src/config.ts +351 -0
  9. package/src/errors.ts +96 -0
  10. package/src/index.ts +43 -0
  11. package/src/mcp/commands.ts +390 -0
  12. package/src/mcp/config.ts +157 -0
  13. package/src/mcp/credential-store.ts +153 -0
  14. package/src/mcp/index.ts +67 -0
  15. package/src/mcp/manager.ts +941 -0
  16. package/src/mcp/oauth.ts +262 -0
  17. package/src/mcp/proxy-tool.ts +213 -0
  18. package/src/mcp/render.ts +164 -0
  19. package/src/mcp/types.ts +63 -0
  20. package/src/paths.ts +48 -0
  21. package/src/questions/ask.ts +134 -0
  22. package/src/questions/index.ts +72 -0
  23. package/src/questions/render.ts +69 -0
  24. package/src/questions/validate.ts +85 -0
  25. package/src/security/env.ts +132 -0
  26. package/src/security/limits.ts +20 -0
  27. package/src/security/ssrf.ts +237 -0
  28. package/src/subagents/activity.ts +132 -0
  29. package/src/subagents/builtins/oracle.md +11 -0
  30. package/src/subagents/builtins/reviewer.md +11 -0
  31. package/src/subagents/builtins/scout.md +12 -0
  32. package/src/subagents/builtins/worker.md +11 -0
  33. package/src/subagents/discover.ts +54 -0
  34. package/src/subagents/herdr.ts +150 -0
  35. package/src/subagents/index.ts +642 -0
  36. package/src/subagents/inspector-tail.d.mts +1 -0
  37. package/src/subagents/inspector-tail.mjs +140 -0
  38. package/src/subagents/render.ts +464 -0
  39. package/src/subagents/runner.ts +468 -0
  40. package/src/subagents/schema.ts +107 -0
  41. package/src/subagents/types.ts +131 -0
  42. package/src/subagents/worktree.ts +131 -0
  43. package/src/todos/index.ts +170 -0
  44. package/src/todos/render.ts +198 -0
  45. package/src/todos/state.ts +310 -0
  46. package/src/ui/render.ts +215 -0
  47. package/src/web/activity.ts +91 -0
  48. package/src/web/cache.ts +153 -0
  49. package/src/web/extract.ts +75 -0
  50. package/src/web/fetch.ts +167 -0
  51. package/src/web/html-to-markdown.ts +284 -0
  52. package/src/web/http.ts +238 -0
  53. package/src/web/index.ts +214 -0
  54. package/src/web/providers/brave.ts +27 -0
  55. package/src/web/providers/duckduckgo.ts +60 -0
  56. package/src/web/providers/exa.ts +29 -0
  57. package/src/web/providers/jina.ts +25 -0
  58. package/src/web/providers/searxng.ts +29 -0
  59. package/src/web/providers/tavily.ts +31 -0
  60. package/src/web/providers/types.ts +75 -0
  61. package/src/web/render.ts +130 -0
  62. package/src/web/search.ts +108 -0
@@ -0,0 +1,237 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import net from "node:net";
3
+
4
+ const BLOCKED_HOSTS = new Set(["localhost", "localhost.localdomain", "0.0.0.0", "::1", "ip6-localhost", "ip6-loopback"]);
5
+
6
+ const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".home.arpa", ".lan"];
7
+
8
+ export type SsrfIssue =
9
+ | "unsupported-protocol"
10
+ | "invalid-url"
11
+ | "blocked-host"
12
+ | "blocked-ip"
13
+ | "dns-failure";
14
+
15
+ export class SsrfError extends Error {
16
+ readonly issue: SsrfIssue;
17
+ constructor(message: string, issue: SsrfIssue) {
18
+ super(message);
19
+ this.name = "SsrfError";
20
+ this.issue = issue;
21
+ }
22
+ }
23
+
24
+ export function parseHttpUrl(raw: string): URL {
25
+ let url: URL;
26
+ try {
27
+ url = new URL(raw);
28
+ } catch {
29
+ throw new SsrfError(`Invalid URL: ${raw}`, "invalid-url");
30
+ }
31
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
32
+ throw new SsrfError(`Only http and https URLs are allowed (got ${url.protocol})`, "unsupported-protocol");
33
+ }
34
+ if (url.username || url.password) {
35
+ throw new SsrfError("URLs with embedded credentials are not allowed", "invalid-url");
36
+ }
37
+ return url;
38
+ }
39
+
40
+ /** Strip IPv6 brackets, a trailing FQDN dot, and case. */
41
+ export function normalizeHostname(hostname: string): string {
42
+ return hostname.replace(/^\[|\]$/g, "").replace(/\.+$/, "").toLowerCase();
43
+ }
44
+
45
+ export function isBlockedHostname(hostname: string): boolean {
46
+ const host = normalizeHostname(hostname);
47
+ if (!host) return true;
48
+ if (BLOCKED_HOSTS.has(host)) return true;
49
+ if (BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
50
+ if (host === "metadata.google.internal" || host === "metadata") return true;
51
+ return false;
52
+ }
53
+
54
+ function isBlockedIpv4(ip: string): boolean {
55
+ const parts = ip.split(".").map((p) => Number(p));
56
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
57
+ const [a, b, c] = parts;
58
+ if (a === 0 || a === 10 || a === 127) return true;
59
+ if (a === 169 && b === 254) return true; // link-local / cloud metadata
60
+ if (a === 172 && b >= 16 && b <= 31) return true;
61
+ if (a === 192 && b === 168) return true;
62
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
63
+ if (a === 192 && b === 0 && c === 0) return true; // IETF protocol assignments
64
+ if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
65
+ if (a >= 224) return true; // multicast / reserved / broadcast
66
+ return false;
67
+ }
68
+
69
+ export function isBlockedIp(ip: string): boolean {
70
+ const version = net.isIP(ip);
71
+ if (!version) return true;
72
+ if (version === 4) return isBlockedIpv4(ip);
73
+
74
+ const groups = expandIpv6(ip);
75
+ if (!groups) return true;
76
+
77
+ // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) addresses.
78
+ const isZeroPrefix = groups.slice(0, 5).every((g) => g === 0);
79
+ if (isZeroPrefix && (groups[5] === 0xffff || groups[5] === 0)) {
80
+ const mapped = `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`;
81
+ if (groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) return true; // :: and ::1
82
+ return isBlockedIpv4(mapped);
83
+ }
84
+
85
+ const [first] = groups;
86
+ if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
87
+ if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
88
+ if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast
89
+ if (first === 0x0064 && groups[1] === 0xff9b) return true; // 64:ff9b::/96 NAT64
90
+ if (first === 0x0100 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0) return true; // 100::/64 discard
91
+ if (first === 0x2001 && groups[1] === 0x0db8) return true; // documentation
92
+ return false;
93
+ }
94
+
95
+ /** Expand any valid IPv6 textual form into eight 16-bit groups. */
96
+ export function expandIpv6(ip: string): number[] | undefined {
97
+ if (!net.isIPv6(ip)) return undefined;
98
+ let text = ip.toLowerCase().replace(/%.*$/, ""); // drop zone id
99
+
100
+ // Trailing dotted-quad form: ::ffff:127.0.0.1
101
+ const dotted = text.match(/(\d{1,3}(?:\.\d{1,3}){3})$/);
102
+ if (dotted) {
103
+ const octets = dotted[1].split(".").map(Number);
104
+ if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return undefined;
105
+ const hi = ((octets[0] << 8) | octets[1]).toString(16);
106
+ const lo = ((octets[2] << 8) | octets[3]).toString(16);
107
+ text = `${text.slice(0, dotted.index)}${hi}:${lo}`;
108
+ }
109
+
110
+ const [head, tail, extra] = text.split("::");
111
+ if (extra !== undefined) return undefined;
112
+ const parse = (part: string): number[] =>
113
+ part
114
+ .split(":")
115
+ .filter((chunk) => chunk.length > 0)
116
+ .map((chunk) => Number.parseInt(chunk, 16));
117
+
118
+ const left = parse(head ?? "");
119
+ const right = tail === undefined ? [] : parse(tail);
120
+ const fill = tail === undefined ? 0 : 8 - left.length - right.length;
121
+ if (fill < 0) return undefined;
122
+ const groups = [...left, ...new Array<number>(Math.max(0, fill)).fill(0), ...right];
123
+ if (groups.length !== 8 || groups.some((g) => !Number.isInteger(g) || g < 0 || g > 0xffff)) return undefined;
124
+ return groups;
125
+ }
126
+
127
+ export interface PinnedAddress {
128
+ address: string;
129
+ family: number;
130
+ }
131
+
132
+ export interface SafeUrl {
133
+ url: URL;
134
+ /**
135
+ * The exact addresses this URL was validated against. Connecting to these
136
+ * instead of re-resolving the hostname is what closes the DNS-rebinding window
137
+ * between validation and connect.
138
+ */
139
+ addresses: PinnedAddress[];
140
+ }
141
+
142
+ export interface SsrfPolicy {
143
+ /**
144
+ * Hosts explicitly trusted by the user, as `host` or `host:port` (a bare host
145
+ * matches any port). Used for deliberately local services such as a self-hosted
146
+ * SearXNG instance.
147
+ */
148
+ allowedHosts?: ReadonlySet<string>;
149
+ }
150
+
151
+ /** True when the user has explicitly allowed this host (optionally per port). */
152
+ export function isAllowedHost(url: URL, policy?: SsrfPolicy): boolean {
153
+ const allowed = policy?.allowedHosts;
154
+ if (!allowed || allowed.size === 0) return false;
155
+ const host = normalizeHostname(url.hostname);
156
+ const port = url.port || (url.protocol === "https:" ? "443" : "80");
157
+ return allowed.has(host) || allowed.has(`${host}:${port}`);
158
+ }
159
+
160
+ function explicitPort(entry: string): string | undefined {
161
+ const withoutScheme = entry.includes("://") ? entry.slice(entry.indexOf("://") + 3) : entry;
162
+ const authority = withoutScheme.split(/[/?#]/, 1)[0].split("@").pop() ?? "";
163
+ const match = authority.startsWith("[") ? /^\[[^\]]+\]:(\d+)$/.exec(authority) : /:(\d+)$/.exec(authority);
164
+ return match?.[1];
165
+ }
166
+
167
+ /** Normalize an allowlist entry the same way `isAllowedHost` normalizes a URL. */
168
+ export function normalizeAllowedHost(entry: string): string | undefined {
169
+ const trimmed = entry.trim();
170
+ if (!trimmed) return undefined;
171
+ try {
172
+ // URL.port intentionally erases explicit default ports. Extract the port
173
+ // from the original authority so `host:80` does not become a host-wide grant.
174
+ const port = explicitPort(trimmed);
175
+ const parsed = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`);
176
+ const host = normalizeHostname(parsed.hostname);
177
+ if (!host) return undefined;
178
+ return port ? `${host}:${port}` : host;
179
+ } catch {
180
+ return undefined;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Validate a URL and return the addresses it resolved to, so the caller can pin
186
+ * the connection to them.
187
+ */
188
+ export async function resolveSafeUrl(raw: string, policy?: SsrfPolicy): Promise<SafeUrl> {
189
+ const url = parseHttpUrl(raw);
190
+ const hostname = normalizeHostname(url.hostname);
191
+ const exempt = isAllowedHost(url, policy);
192
+
193
+ if (!exempt && isBlockedHostname(url.hostname)) {
194
+ throw new SsrfError(`Blocked host: ${url.hostname}`, "blocked-host");
195
+ }
196
+
197
+ const literal = net.isIP(hostname);
198
+ if (literal) {
199
+ if (!exempt && isBlockedIp(hostname)) {
200
+ throw new SsrfError(`Blocked IP address: ${url.hostname}`, "blocked-ip");
201
+ }
202
+ return { url, addresses: [{ address: hostname, family: literal }] };
203
+ }
204
+
205
+ let results: Array<{ address: string; family: number }>;
206
+ try {
207
+ results = await lookup(hostname, { all: true, verbatim: true });
208
+ } catch {
209
+ throw new SsrfError(`DNS lookup failed for ${url.hostname}`, "dns-failure");
210
+ }
211
+ if (results.length === 0) {
212
+ throw new SsrfError(`DNS lookup returned no addresses for ${url.hostname}`, "dns-failure");
213
+ }
214
+ if (!exempt) {
215
+ for (const result of results) {
216
+ if (isBlockedIp(result.address)) {
217
+ throw new SsrfError(`Host ${url.hostname} resolves to blocked address ${result.address}`, "blocked-ip");
218
+ }
219
+ }
220
+ }
221
+ return {
222
+ url,
223
+ addresses: results.map((result) => ({ address: result.address, family: result.family || net.isIP(result.address) })),
224
+ };
225
+ }
226
+
227
+ export async function assertSafeUrl(raw: string, policy?: SsrfPolicy): Promise<URL> {
228
+ return (await resolveSafeUrl(raw, policy)).url;
229
+ }
230
+
231
+ export function redirectUrl(current: URL, location: string): URL {
232
+ try {
233
+ return new URL(location, current);
234
+ } catch {
235
+ throw new SsrfError(`Invalid redirect location: ${location}`, "invalid-url");
236
+ }
237
+ }
@@ -0,0 +1,132 @@
1
+ import { GLYPH, oneLine } from "../ui/render.ts";
2
+
3
+ const MAX_EVENTS = 48;
4
+ const SECRET_KEY = /(?:api[_-]?key|token|secret|password|passwd|authorization|cookie|credential)/i;
5
+ const ARG_KEYS = ["command", "path", "file", "file_path", "filePath", "query", "url", "pattern", "glob", "target", "name"];
6
+
7
+ export type TraceEvent =
8
+ | { kind: "tool"; id?: string; name: string; args?: string; status: "running" | "ok" | "error" }
9
+ | { kind: "text"; text: string };
10
+
11
+ export interface LiveTrace {
12
+ activity: string;
13
+ events: TraceEvent[];
14
+ textBuffer: string;
15
+ }
16
+
17
+ export function emptyTrace(): LiveTrace {
18
+ return { activity: "", events: [], textBuffer: "" };
19
+ }
20
+
21
+ function pushEvent(trace: LiveTrace, event: TraceEvent): void {
22
+ const last = trace.events.at(-1);
23
+ if (event.kind === "text" && last?.kind === "text" && last.text === event.text) return;
24
+ trace.events.push(event);
25
+ if (trace.events.length > MAX_EVENTS) trace.events.splice(0, trace.events.length - MAX_EVENTS);
26
+ }
27
+
28
+ function stringArg(value: unknown): string | undefined {
29
+ if (typeof value === "string" && value.trim()) return oneLine(value, 48);
30
+ return undefined;
31
+ }
32
+
33
+ export function summarizeToolArgs(args: unknown): string | undefined {
34
+ if (!args || typeof args !== "object") return undefined;
35
+ const rec = args as Record<string, unknown>;
36
+ for (const key of ARG_KEYS) {
37
+ if (SECRET_KEY.test(key)) continue;
38
+ const value = stringArg(rec[key]);
39
+ if (value) return value;
40
+ }
41
+ for (const [key, raw] of Object.entries(rec)) {
42
+ if (SECRET_KEY.test(key)) continue;
43
+ const value = stringArg(raw);
44
+ if (value) return value;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ export function summarizeTool(name: string, args: unknown): string {
50
+ const detail = summarizeToolArgs(args);
51
+ const label = name.trim() || "tool";
52
+ return detail ? `${label} ${GLYPH.sep} ${detail}` : label;
53
+ }
54
+
55
+ function recordActivity(trace: LiveTrace, activity: string): boolean {
56
+ const next = oneLine(activity, 64);
57
+ if (!next || next === trace.activity) return false;
58
+ trace.activity = next;
59
+ return true;
60
+ }
61
+
62
+ function findTool(trace: LiveTrace, id: string | undefined, name: string): Extract<TraceEvent, { kind: "tool" }> | undefined {
63
+ if (id) {
64
+ for (let i = trace.events.length - 1; i >= 0; i--) {
65
+ const event = trace.events[i];
66
+ if (event.kind === "tool" && event.id === id) return event;
67
+ }
68
+ }
69
+ for (let i = trace.events.length - 1; i >= 0; i--) {
70
+ const event = trace.events[i];
71
+ if (event.kind === "tool" && event.name === name && event.status === "running") return event;
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ /**
77
+ * Fold a child `pi --mode json` event into a live trace.
78
+ * Returns true when the fleet/inspector should redraw.
79
+ */
80
+ export function applySessionEvent(trace: LiveTrace, event: unknown): boolean {
81
+ if (!event || typeof event !== "object") return false;
82
+ const rec = event as {
83
+ type?: string;
84
+ toolCallId?: string;
85
+ toolName?: string;
86
+ isError?: boolean;
87
+ args?: unknown;
88
+ assistantMessageEvent?: { type?: string; delta?: string };
89
+ message?: { role?: string; content?: unknown; errorMessage?: string };
90
+ };
91
+
92
+ if (rec.toolName === "subagent_result") return false;
93
+
94
+ if (rec.type === "tool_execution_start" && rec.toolName) {
95
+ const args = summarizeToolArgs(rec.args);
96
+ pushEvent(trace, { kind: "tool", id: rec.toolCallId, name: rec.toolName, args, status: "running" });
97
+ return recordActivity(trace, summarizeTool(rec.toolName, rec.args)) || true;
98
+ }
99
+
100
+ if (rec.type === "tool_execution_end" && rec.toolName) {
101
+ const tool = findTool(trace, rec.toolCallId, rec.toolName);
102
+ const status = rec.isError ? "error" : "ok";
103
+ if (tool) tool.status = status;
104
+ else pushEvent(trace, { kind: "tool", id: rec.toolCallId, name: rec.toolName, status });
105
+ return recordActivity(trace, rec.isError ? `${rec.toolName} failed` : summarizeTool(rec.toolName, rec.args)) || true;
106
+ }
107
+
108
+ if (rec.type === "message_update") {
109
+ const delta = rec.assistantMessageEvent;
110
+ if (delta?.type === "text_delta" && typeof delta.delta === "string" && delta.delta) {
111
+ trace.textBuffer += delta.delta;
112
+ return recordActivity(trace, trace.textBuffer);
113
+ }
114
+ return false;
115
+ }
116
+
117
+ if (rec.type === "message_end" && rec.message?.role === "assistant") {
118
+ if (rec.message.errorMessage) {
119
+ pushEvent(trace, { kind: "text", text: oneLine(rec.message.errorMessage, 120) });
120
+ return recordActivity(trace, rec.message.errorMessage) || true;
121
+ }
122
+ const text = trace.textBuffer.trim();
123
+ trace.textBuffer = "";
124
+ if (text) {
125
+ pushEvent(trace, { kind: "text", text: oneLine(text, 160) });
126
+ return recordActivity(trace, text) || true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ return false;
132
+ }
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: oracle
3
+ description: Second opinion before acting. Challenge assumptions. Do not edit files.
4
+ tools: read, grep, find, ls
5
+ ---
6
+ You are an oracle. Challenge the plan or diagnosis in the task.
7
+
8
+ Rules:
9
+ - Do not edit files.
10
+ - Surface hidden assumptions, alternative explanations, and missing evidence.
11
+ - Be terse. Lead with the verdict, then the strongest objections, then what would change your mind.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: reviewer
3
+ description: Code review against the task. Check correctness, tests, edge cases, and simplicity. Read-only by default.
4
+ tools: read, grep, find, ls, bash
5
+ ---
6
+ You are a reviewer. Inspect the current change or described task.
7
+
8
+ Rules:
9
+ - Do not implement new features. Small obvious fixes may be described, not applied, unless the task explicitly asks you to edit.
10
+ - Look for correctness bugs, missing tests, edge cases, and unnecessary complexity.
11
+ - Return a concise review: findings first, then residual risks, then what looks solid.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: scout
3
+ description: Fast local codebase recon. Find relevant files, entry points, data flow, and risks. Read-only.
4
+ tools: read, grep, find, ls
5
+ ---
6
+ You are a scout. Explore the repository to answer the task with evidence.
7
+
8
+ Rules:
9
+ - Prefer read, grep, find, and ls. Do not edit files.
10
+ - Cite file paths and brief quotes.
11
+ - Return a concise recon brief: relevant files, how data flows, and risks.
12
+ - If something is unclear, say what you could not verify.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: worker
3
+ description: Implementation agent. Edit files, run checks, and escalate unclear decisions instead of guessing.
4
+ ---
5
+ You are a worker. Implement the assigned task in this repository.
6
+
7
+ Rules:
8
+ - Make focused changes. Do not expand scope.
9
+ - Validate with tests or typechecks when they exist.
10
+ - If a decision needs the user, stop and report the options instead of guessing.
11
+ - Return what you changed, how you verified it, and anything left unfinished.
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { getAgentDir, getProjectPiDir } from "../paths.ts";
5
+ import { parseAgentMarkdown, type AgentDefinition, type AgentScope } from "./types.ts";
6
+
7
+ const here = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ function loadDir(dir: string, source: AgentDefinition["source"]): AgentDefinition[] {
10
+ let entries: string[];
11
+ try {
12
+ entries = fs.readdirSync(dir);
13
+ } catch {
14
+ return [];
15
+ }
16
+ const out: AgentDefinition[] = [];
17
+ for (const entry of entries.sort()) {
18
+ if (!entry.toLowerCase().endsWith(".md")) continue;
19
+ const filePath = path.join(dir, entry);
20
+ try {
21
+ if (!fs.statSync(filePath).isFile()) continue;
22
+ const markdown = fs.readFileSync(filePath, "utf8");
23
+ const agent = parseAgentMarkdown(markdown, path.basename(entry, path.extname(entry)), source, filePath);
24
+ if (agent.name) out.push(agent);
25
+ } catch {
26
+ // Skip unreadable or malformed agent files rather than failing discovery.
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ function loadBuiltins(): AgentDefinition[] {
33
+ return loadDir(path.join(here, "builtins"), "builtin");
34
+ }
35
+
36
+ export function discoverAgents(cwd: string, scope: AgentScope): AgentDefinition[] {
37
+ const byName = new Map<string, AgentDefinition>();
38
+ for (const agent of loadBuiltins()) byName.set(agent.name, agent);
39
+ if (scope === "user" || scope === "both") {
40
+ for (const agent of loadDir(path.join(getAgentDir(), "agents"), "user")) byName.set(agent.name, agent);
41
+ }
42
+ if (scope === "project" || scope === "both") {
43
+ for (const agent of loadDir(path.join(getProjectPiDir(cwd), "agents"), "project")) byName.set(agent.name, agent);
44
+ }
45
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
46
+ }
47
+
48
+ export function formatAgentList(agents: AgentDefinition[]): string {
49
+ if (agents.length === 0) return "none";
50
+ const width = Math.max(...agents.map((a) => a.name.length));
51
+ return agents
52
+ .map((a) => `${a.name.padEnd(width)} [${a.source}] ${a.description || "(no description)"}`)
53
+ .join("\n");
54
+ }
@@ -0,0 +1,150 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export type HerdrErrorCode = "HERDR_UNAVAILABLE" | "INVALID_RESPONSE" | "TIMEOUT";
4
+
5
+ export type HerdrResult<T> = { ok: true; data: T } | { ok: false; error: { code: HerdrErrorCode; message: string } };
6
+
7
+ export type SpawnLike = typeof spawn;
8
+
9
+ const DEFAULT_TIMEOUT_MS = 15_000;
10
+
11
+ function herdrBin(): string {
12
+ return process.env.HERDR_BIN?.trim() || "herdr";
13
+ }
14
+
15
+ function fail(code: HerdrErrorCode, message: string): HerdrResult<never> {
16
+ return { ok: false, error: { code, message } };
17
+ }
18
+
19
+ /** Herdr replies with `{ result: ... }` on success or a bare object on some commands; tolerate both. */
20
+ function unwrap(stdout: string): unknown {
21
+ const trimmed = stdout.trim();
22
+ if (!trimmed) return undefined;
23
+ try {
24
+ const parsed = JSON.parse(trimmed);
25
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "result" in (parsed as Record<string, unknown>)) {
26
+ return (parsed as Record<string, unknown>).result;
27
+ }
28
+ return parsed;
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ /** Run one `herdr` subcommand and parse its JSON reply. Never throws. */
35
+ export function runHerdr(args: string[], options: { timeoutMs?: number; spawnImpl?: SpawnLike } = {}): Promise<HerdrResult<unknown>> {
36
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
37
+ const spawnImpl = options.spawnImpl ?? spawn;
38
+ return new Promise((resolve) => {
39
+ let proc: ReturnType<typeof spawn>;
40
+ try {
41
+ proc = spawnImpl(herdrBin(), args, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
42
+ } catch (error) {
43
+ resolve(fail("HERDR_UNAVAILABLE", `Could not start herdr: ${(error as Error).message}`));
44
+ return;
45
+ }
46
+
47
+ let stdout = "";
48
+ let stderr = "";
49
+ let settled = false;
50
+ const finish = (result: HerdrResult<unknown>) => {
51
+ if (settled) return;
52
+ settled = true;
53
+ clearTimeout(timer);
54
+ resolve(result);
55
+ };
56
+
57
+ const timer = setTimeout(() => {
58
+ proc.kill("SIGKILL");
59
+ finish(fail("TIMEOUT", `herdr ${args.join(" ")} timed out after ${timeoutMs}ms.`));
60
+ }, timeoutMs);
61
+ timer.unref?.();
62
+
63
+ proc.stdout?.setEncoding("utf8");
64
+ proc.stdout?.on("data", (chunk: string) => {
65
+ stdout += chunk;
66
+ });
67
+ proc.stderr?.setEncoding("utf8");
68
+ proc.stderr?.on("data", (chunk: string) => {
69
+ stderr += chunk;
70
+ });
71
+ proc.on("error", (error) => {
72
+ const code = (error as NodeJS.ErrnoException).code;
73
+ finish(
74
+ fail(
75
+ "HERDR_UNAVAILABLE",
76
+ code === "ENOENT"
77
+ ? "herdr is not installed or not on PATH. Install Herdr, or set HERDR_BIN."
78
+ : error.message,
79
+ ),
80
+ );
81
+ });
82
+ proc.on("close", (exitCode) => {
83
+ if (exitCode !== 0) {
84
+ finish(fail("INVALID_RESPONSE", stderr.trim() || `herdr ${args[0] ?? ""} exited with code ${exitCode}.`));
85
+ return;
86
+ }
87
+ finish({ ok: true, data: unwrap(stdout) });
88
+ });
89
+ });
90
+ }
91
+
92
+ function extractPaneId(data: unknown): string | undefined {
93
+ if (!data || typeof data !== "object" || Array.isArray(data)) return undefined;
94
+ const record = data as Record<string, unknown>;
95
+ const pane =
96
+ record.pane && typeof record.pane === "object" && !Array.isArray(record.pane)
97
+ ? (record.pane as Record<string, unknown>)
98
+ : record;
99
+ for (const key of ["pane_id", "paneId", "id"]) {
100
+ const value = pane[key];
101
+ if (typeof value === "string" && value) return value;
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ function quoteArg(value: string, platform: NodeJS.Platform): string {
107
+ if (platform === "win32") return `"${value.replaceAll('"', '\\"')}"`;
108
+ return `'${value.replaceAll("'", `'\\''`)}'`;
109
+ }
110
+
111
+ /**
112
+ * Build a single shell-quoted command string for `herdr pane run`, which executes it
113
+ * through the pane's own shell. Every argument must come from values we generate
114
+ * ourselves (paths, flags) -- never from model or user-controlled text -- so quoting
115
+ * mistakes cannot become a shell-injection path from an untrusted source.
116
+ */
117
+ export function buildPaneCommand(exe: string, args: string[], platform: NodeJS.Platform = process.platform): string {
118
+ return [exe, ...args].map((part) => quoteArg(part, platform)).join(" ");
119
+ }
120
+
121
+ export interface OpenInspectorPaneResult {
122
+ ok: boolean;
123
+ paneId?: string;
124
+ message?: string;
125
+ }
126
+
127
+ /** Open a new Herdr pane beside the current one and run `command` in it. Best-effort; never throws. */
128
+ export async function openInspectorPane(
129
+ options: { cwd: string; command: string; spawnImpl?: SpawnLike },
130
+ ): Promise<OpenInspectorPaneResult> {
131
+ const probe = await runHerdr(["--version"], { timeoutMs: 5_000, spawnImpl: options.spawnImpl });
132
+ if (!probe.ok) return { ok: false, message: `Herdr is not available: ${probe.error.message}` };
133
+
134
+ const split = await runHerdr(
135
+ ["pane", "split", "--current", "--direction", "right", "--cwd", options.cwd, "--no-focus"],
136
+ { spawnImpl: options.spawnImpl },
137
+ );
138
+ if (!split.ok) return { ok: false, message: `Could not open a Herdr pane: ${split.error.message}` };
139
+
140
+ const paneId = extractPaneId(split.data);
141
+ if (!paneId) return { ok: false, message: "Herdr did not return a pane id for the new pane." };
142
+
143
+ const started = await runHerdr(["pane", "run", paneId, options.command], { spawnImpl: options.spawnImpl });
144
+ if (!started.ok) {
145
+ await runHerdr(["pane", "close", paneId], { timeoutMs: 5_000, spawnImpl: options.spawnImpl });
146
+ return { ok: false, message: `Could not start the inspector in the Herdr pane: ${started.error.message}` };
147
+ }
148
+
149
+ return { ok: true, paneId };
150
+ }