@rohaquinlop/pi-subagents 1.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.
package/lib/helpers.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Pure helper functions for agent discovery, frontmatter parsing, and tool normalization.
3
+ * These functions use only Node.js built-ins (fs, path) — no pi package imports.
4
+ */
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import type { AgentConfig } from "./types";
8
+
9
+ export type { AgentConfig };
10
+
11
+ /**
12
+ * Converts comma-separated tool strings to arrays, handles trimming.
13
+ * Accepts either a string or an already-normalized array.
14
+ */
15
+ export function normalizeTools(tools: string | string[]): string[] {
16
+ if (Array.isArray(tools)) return tools;
17
+ return tools.split(",").map((t) => t.trim()).filter(Boolean);
18
+ }
19
+
20
+ /**
21
+ * Parses a markdown file with YAML frontmatter into an AgentConfig.
22
+ * Returns null if the file has no valid frontmatter or is missing required fields.
23
+ */
24
+ export function parseAgentMd(content: string, filePath: string): AgentConfig | null {
25
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
26
+ if (!match) return null;
27
+
28
+ const frontmatter = match[1];
29
+ const systemPrompt = match[2].trim();
30
+
31
+ // Parse frontmatter lines
32
+ const fields: Record<string, string> = {};
33
+ for (const line of frontmatter.split("\n")) {
34
+ const colonIdx = line.indexOf(":");
35
+ if (colonIdx === -1) continue;
36
+ const key = line.slice(0, colonIdx).trim();
37
+ const value = line.slice(colonIdx + 1).trim();
38
+ fields[key] = value;
39
+ }
40
+
41
+ const name = fields.name;
42
+ const description = fields.description;
43
+ const toolsRaw = fields.tools;
44
+ const model = fields.model;
45
+ const thinking = fields.thinking;
46
+
47
+ if (!name || !description || !toolsRaw || !model || !thinking) return null;
48
+
49
+ const tools = normalizeTools(toolsRaw);
50
+ const subagentAgents = fields.subagent_agents
51
+ ? normalizeTools(fields.subagent_agents)
52
+ : undefined;
53
+
54
+ return { name, description, tools, model, thinking, systemPrompt, filePath, subagentAgents };
55
+ }
56
+
57
+ /**
58
+ * Scans a directory for .md files and parses them into AgentConfigs.
59
+ * Returns only successfully parsed agents (invalid files are silently skipped).
60
+ */
61
+ export function discoverAgents(agentsDir: string): AgentConfig[] {
62
+ const agents: AgentConfig[] = [];
63
+ if (!fs.existsSync(agentsDir)) return agents;
64
+
65
+ for (const entry of fs.readdirSync(agentsDir)) {
66
+ if (!entry.endsWith(".md")) continue;
67
+ const filePath = path.join(agentsDir, entry);
68
+ try {
69
+ const content = fs.readFileSync(filePath, "utf-8");
70
+ const agent = parseAgentMd(content, filePath);
71
+ if (agent) agents.push(agent);
72
+ } catch {
73
+ // Skip files that can't be read
74
+ }
75
+ }
76
+ return agents;
77
+ }
78
+
79
+ /**
80
+ * Merges built-in and user agents. User agents override built-in agents with the same name.
81
+ */
82
+ export function mergeAgents(builtIn: AgentConfig[], user: AgentConfig[]): AgentConfig[] {
83
+ const byName = new Map<string, AgentConfig>();
84
+ for (const agent of builtIn) {
85
+ byName.set(agent.name, agent);
86
+ }
87
+ for (const agent of user) {
88
+ byName.set(agent.name, agent);
89
+ }
90
+ return Array.from(byName.values());
91
+ }
92
+
93
+ /**
94
+ * Parses PI_SUBAGENT_ALLOWED env var into a Set of agent names.
95
+ * Returns null if the env var is not set or empty (meaning no restriction).
96
+ */
97
+ export function parseAllowlist(envVar: string | undefined): Set<string> | null {
98
+ if (!envVar) return null;
99
+ const list = envVar.split(",").map((s) => s.trim()).filter(Boolean);
100
+ return list.length > 0 ? new Set(list) : null;
101
+ }
package/lib/types.ts ADDED
@@ -0,0 +1,10 @@
1
+ export interface AgentConfig {
2
+ name: string;
3
+ description: string;
4
+ tools: string[];
5
+ model: string;
6
+ thinking: string;
7
+ systemPrompt: string;
8
+ filePath: string;
9
+ subagentAgents?: string[];
10
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@rohaquinlop/pi-subagents",
3
+ "version": "1.1.0",
4
+ "description": "Pi extension for delegating tasks to subagents — parallel execution, agent discovery, and TUI rendering",
5
+ "keywords": ["pi-package", "subagent", "parallel", "coding-agent", "extension"],
6
+ "license": "MIT",
7
+ "author": "rohaquinlop",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/rohaquinlop/pi-subagents.git"
11
+ },
12
+ "files": [
13
+ "index.ts",
14
+ "agents/",
15
+ "tools/",
16
+ "lib/",
17
+ "README.md"
18
+ ],
19
+ "pi": {
20
+ "extensions": ["./index.ts"]
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-coding-agent": "*",
24
+ "@earendil-works/pi-tui": "*",
25
+ "@sinclair/typebox": "*"
26
+ },
27
+ "scripts": {
28
+ "test": "vitest run",
29
+ "test:watch": "vitest"
30
+ },
31
+ "devDependencies": {
32
+ "vitest": "^3.2.0"
33
+ }
34
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Safe bash extension for worker subagent.
3
+ * Wraps the built-in bash tool with dangerous command blocking.
4
+ */
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { createBashTool } from "@earendil-works/pi-coding-agent";
7
+ import { Type } from "@sinclair/typebox";
8
+
9
+ const DANGEROUS_PATTERNS = [
10
+ /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
11
+ /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
12
+ /\bsudo\b/,
13
+ /\bmkfs\b/,
14
+ /\bdd\s+if=/,
15
+ /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
16
+ />\s*\/dev\/[sh]d[a-z]/,
17
+ /\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
18
+ /\bchown\s+(-[a-zA-Z]+\s+)?root/,
19
+ /\bcurl\s.*\|\s*(ba)?sh/,
20
+ /\bwget\s.*\|\s*(ba)?sh/,
21
+ /\bshutdown\b/,
22
+ /\breboot\b/,
23
+ /\binit\s+0\b/,
24
+ /\bkill\s+-9\s+1\b/,
25
+ /\bkillall\b/,
26
+ ];
27
+
28
+ function isDangerous(command: string): string | null {
29
+ const normalized = command.replace(/\\\n/g, " ");
30
+ for (const pattern of DANGEROUS_PATTERNS) {
31
+ if (pattern.test(normalized)) {
32
+ return `Command blocked by safe_bash: matches dangerous pattern ${pattern}`;
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+
38
+ export default function (pi: ExtensionAPI) {
39
+ const bashTool = createBashTool(process.cwd());
40
+
41
+ pi.registerTool({
42
+ name: "safe_bash",
43
+ label: "Safe Bash",
44
+ description:
45
+ "Execute a bash command. Blocks dangerous commands (rm -rf /, sudo, mkfs, etc.).",
46
+ parameters: Type.Object({
47
+ command: Type.String({ description: "Bash command to execute" }),
48
+ timeout: Type.Optional(
49
+ Type.Number({ description: "Timeout in seconds (optional)" }),
50
+ ),
51
+ }),
52
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
53
+ const danger = isDangerous(params.command);
54
+ if (danger) {
55
+ throw new Error(danger);
56
+ }
57
+ return bashTool.execute(toolCallId, params, signal, onUpdate);
58
+ },
59
+ });
60
+ }