pi-subagents-lite 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Paramonov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # pi-subagents-lite
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pi-subagents-lite)](https://www.npmjs.com/package/pi-subagents-lite)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Lightweight sub-agents for [pi](https://pi.dev). A focused fork of [pi-subagents](https://github.com/tintinweb/pi-subagents) with reduced surface area — spawn specialized agents with isolated sessions, tools, and models.
7
+
8
+ ## Features
9
+
10
+ - **Agent tool** — spawn foreground or background sub-agents with `Agent({ prompt, description, agent, run_in_background, ... })`
11
+ - **Auto-delivered results** — background agents notify you on completion, no polling needed
12
+ - **steer_subagent** — inject messages into running agents mid-execution
13
+ - **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter
14
+ - **Turn limits** — soft limit with wrap-up warning, then hard abort
15
+ - **Per-model concurrency** — configurable slot limits per model
16
+ - **Stealth tools** — minimal prompt footprint (`.description`), no promptSnippet/guidelines
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ # Global
22
+ pi install npm:pi-subagents-lite
23
+
24
+ # Project-local
25
+ pi install -l npm:pi-subagents-lite
26
+
27
+ # Try without installing
28
+ pi -e npm:pi-subagents-lite
29
+
30
+ # From git
31
+ pi install git:github.com/AlexParamonov/pi-subagents-lite
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```ts
37
+ // Spawn a foreground agent
38
+ Agent({
39
+ agent: "Explore",
40
+ prompt: "Find all files that handle authentication",
41
+ description: "Find auth files",
42
+ })
43
+
44
+ // Spawn a background agent (result auto-delivered)
45
+ Agent({
46
+ agent: "Explore",
47
+ prompt: "Find all files that handle authentication",
48
+ description: "Find auth files",
49
+ run_in_background: true,
50
+ })
51
+ ```
52
+
53
+ ## Custom Agent Types
54
+
55
+ Define agents in `.pi/agents/<name>.md` with YAML frontmatter:
56
+
57
+ ```markdown
58
+ ---
59
+ description: Review code for security issues
60
+ tools: [read, bash, grep, find]
61
+ extensions: false
62
+ skills: false
63
+ max_turns: 5
64
+ ---
65
+
66
+ You are a security review specialist. Analyze code for vulnerabilities,
67
+ focusing on injection flaws, auth bypasses, and insecure defaults.
68
+ ```
69
+
70
+ ## Commands
71
+
72
+ - `/agents` — Management menu: model settings, concurrency, running agents, agent types, agent briefing
73
+ - `/steer` — Steer a running agent
74
+
75
+ ## Requirements
76
+
77
+ - Node.js >= 18
78
+ - pi >= 0.74.0
79
+
80
+ ## License
81
+
82
+ MIT
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "pi-subagents-lite",
3
+ "version": "0.2.0",
4
+ "description": "Lightweight sub-agents for pi — spawn specialized agents with isolated sessions, tools, and models.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-extension",
9
+ "subagent",
10
+ "agent"
11
+ ],
12
+ "author": "AlexParamonov",
13
+ "license": "MIT",
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/AlexParamonov/pi-subagents-lite.git"
20
+ },
21
+ "homepage": "https://github.com/AlexParamonov/pi-subagents-lite#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/AlexParamonov/pi-subagents-lite/issues"
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-ai": ">=0.74.0",
27
+ "@earendil-works/pi-coding-agent": ">=0.74.0",
28
+ "@earendil-works/pi-tui": ">=0.74.0"
29
+ },
30
+ "dependencies": {
31
+ "@sinclair/typebox": "^0.34.49"
32
+ },
33
+ "files": [
34
+ "src/",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest"
42
+ },
43
+ "pi": {
44
+ "extensions": [
45
+ "./src/index.ts"
46
+ ]
47
+ },
48
+ "devDependencies": {
49
+ "typescript": "^6.0.3",
50
+ "vitest": "^4.1.7"
51
+ }
52
+ }
@@ -0,0 +1,412 @@
1
+ /**
2
+ * agent-discovery.ts — Agent file discovery, parsing, and config merging.
3
+ *
4
+ * Extended from subagent-lazy/agent-discovery.ts with full frontmatter support.
5
+ *
6
+ * Scans:
7
+ * ~/.pi/agent/agents/*.md (user agents)
8
+ * <project>/.pi/agents/*.md (project agents)
9
+ *
10
+ * Parses YAML frontmatter, extracts all fields, produces AgentConfig objects.
11
+ * Merges with per-field precedence: default < user < project.
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import type { AgentConfig, ThinkingLevel } from "./types.js";
17
+
18
+ /* ------------------------------------------------------------------ */
19
+ /* Validation helpers */
20
+ /* ------------------------------------------------------------------ */
21
+
22
+ const VALID_THINKING_LEVELS: readonly ThinkingLevel[] = [
23
+ "off", "minimal", "low", "medium", "high", "xhigh",
24
+ ] as const;
25
+
26
+ /** Validate and narrow a raw thinking value to ThinkingLevel. */
27
+ function validateThinking(raw: string | undefined): ThinkingLevel | undefined {
28
+ if (raw === undefined) return undefined;
29
+ return VALID_THINKING_LEVELS.includes(raw as ThinkingLevel) ? (raw as ThinkingLevel) : undefined;
30
+ }
31
+
32
+ /* ------------------------------------------------------------------ */
33
+ /* Types */
34
+ /* ------------------------------------------------------------------ */
35
+
36
+ /** Raw agent config as parsed from .md frontmatter. */
37
+ export interface AgentConfigFromMd {
38
+ name?: string;
39
+ display_name?: string;
40
+ description?: string;
41
+ tools?: string[];
42
+ extensions?: boolean | string[];
43
+ skills?: boolean | string[];
44
+ model?: string;
45
+ thinking?: ThinkingLevel;
46
+ max_turns?: number;
47
+ disallowed_tools?: string[];
48
+ enabled?: boolean;
49
+ systemPrompt: string;
50
+ source: "user" | "project";
51
+ }
52
+
53
+ /* ------------------------------------------------------------------ */
54
+ /* Simple frontmatter parser */
55
+ /* ------------------------------------------------------------------ */
56
+
57
+ /**
58
+ * Naive YAML frontmatter splitter.
59
+ *
60
+ * Handles triple-dash delimited frontmatter blocks. Does NOT parse nested
61
+ * YAML structures or complex types — only flat key: value pairs and
62
+ * YAML array syntax (lines starting with "- ").
63
+ *
64
+ * Returns { frontmatter: Record<string, unknown>, body: string }.
65
+ */
66
+ function parseFrontmatter(
67
+ content: string,
68
+ ): { frontmatter: Record<string, unknown>; body: string } {
69
+ if (!content) {
70
+ return { frontmatter: {}, body: "" };
71
+ }
72
+
73
+ // Check for triple-dash delimited frontmatter
74
+ if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
75
+ return { frontmatter: {}, body: content };
76
+ }
77
+
78
+ // Find closing ---
79
+ const endIdx = content.indexOf("\n---\n", 4);
80
+ if (endIdx === -1) {
81
+ return { frontmatter: {}, body: content };
82
+ }
83
+
84
+ const fmRaw = content.slice(4, endIdx);
85
+ const body = content.slice(endIdx + 5).trim();
86
+
87
+ const frontmatter: Record<string, unknown> = {};
88
+ let currentKey: string | null = null;
89
+ let currentValues: string[] | null = null;
90
+
91
+ for (const line of fmRaw.split("\n")) {
92
+ const trimmed = line.trim();
93
+
94
+ // Skip empty lines
95
+ if (!trimmed) continue;
96
+
97
+ // Array item (continuation of previous key)
98
+ if (trimmed.startsWith("- ")) {
99
+ if (currentKey) {
100
+ if (!currentValues) currentValues = [];
101
+ currentValues.push(trimmed.slice(2).trim());
102
+ }
103
+ continue;
104
+ }
105
+
106
+ // Flush previous array before processing a new key
107
+ if (currentKey && currentValues) {
108
+ frontmatter[currentKey] = currentValues;
109
+ currentValues = null;
110
+ }
111
+
112
+ const colonIdx = trimmed.indexOf(":");
113
+ if (colonIdx === -1) {
114
+ currentKey = trimmed;
115
+ continue;
116
+ }
117
+
118
+ currentKey = trimmed.slice(0, colonIdx).trim();
119
+ const rawValue = trimmed.slice(colonIdx + 1).trim();
120
+
121
+ if (!rawValue) {
122
+ // Might be followed by array items
123
+ currentValues = [];
124
+ continue;
125
+ }
126
+
127
+ // Strip surrounding quotes if present (YAML convention)
128
+ frontmatter[currentKey] = rawValue.replace(/^['"]|['"]$/g, '');
129
+ currentValues = null;
130
+ }
131
+
132
+ // Flush trailing array items
133
+ if (currentKey && currentValues) {
134
+ frontmatter[currentKey] = currentValues;
135
+ }
136
+
137
+ return { frontmatter, body };
138
+ }
139
+
140
+ /* ------------------------------------------------------------------ */
141
+ /* parseExtensions */
142
+ /* ------------------------------------------------------------------ */
143
+
144
+ /** Split comma-separated string, trim whitespace, and remove empty entries. */
145
+ function splitCommaList(value: string): string[] {
146
+ return value
147
+ .split(",")
148
+ .map((s) => s.trim())
149
+ .filter((s) => s.length > 0);
150
+ }
151
+
152
+ /**
153
+ * Parse the extensions/skills field from frontmatter.
154
+ *
155
+ * - false / "false" / "none" → false
156
+ * - true / "true" / "all" → true
157
+ * - Comma-separated string → string[]
158
+ * - undefined → undefined
159
+ */
160
+ export function parseExtensions(
161
+ raw: unknown,
162
+ ): boolean | string[] | undefined {
163
+ if (raw === false || raw === "false" || raw === "none") {
164
+ return false;
165
+ }
166
+ if (raw === true || raw === "true" || raw === "all") {
167
+ return true;
168
+ }
169
+ if (typeof raw === "string" && raw.length > 0) {
170
+ return splitCommaList(raw);
171
+ }
172
+ if (Array.isArray(raw)) {
173
+ return raw.map(String);
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ /* ------------------------------------------------------------------ */
179
+ /* Frontmatter value helpers */
180
+ /* ------------------------------------------------------------------ */
181
+
182
+ /** Extract a non-empty string value from frontmatter. */
183
+ export function parseString(
184
+ frontmatter: Record<string, unknown>,
185
+ key: string,
186
+ ): string | undefined {
187
+ const v = frontmatter[key];
188
+ return typeof v === "string" && v.length > 0 ? v : undefined;
189
+ }
190
+
191
+ /** Extract a string array from frontmatter (array or comma-separated string). */
192
+ export function parseStringArray(
193
+ frontmatter: Record<string, unknown>,
194
+ key: string,
195
+ ): string[] | undefined {
196
+ const v = frontmatter[key];
197
+ if (Array.isArray(v)) {
198
+ return v.map(String);
199
+ }
200
+ if (typeof v === "string" && v.length > 0) {
201
+ return splitCommaList(v);
202
+ }
203
+ return undefined;
204
+ }
205
+
206
+ /**
207
+ * Build an object containing only the entries whose value is not undefined.
208
+ * Used to transform AgentConfigFromMd fields into a Partial<AgentConfig>
209
+ * without 14 repetitive `if (x !== undefined)` blocks.
210
+ */
211
+ function compactDefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
212
+ return Object.fromEntries(
213
+ Object.entries(obj).filter(([_, v]) => v !== undefined),
214
+ ) as Partial<T>;
215
+ }
216
+
217
+ /* ------------------------------------------------------------------ */
218
+ /* parseAgentFile */
219
+ /* ------------------------------------------------------------------ */
220
+
221
+ /**
222
+ * Parse a single agent .md file into AgentConfigFromMd.
223
+ *
224
+ * @param content - Raw file content
225
+ * @param filename - Filename (for context, currently unused)
226
+ * @param source - Source designation ("user" or "project")
227
+ * @returns Parsed agent config with frontmatter and body
228
+ */
229
+ export function parseAgentFile(
230
+ content: string,
231
+ _filename: string,
232
+ source: "user" | "project",
233
+ ): AgentConfigFromMd {
234
+ const { frontmatter, body } = parseFrontmatter(content);
235
+
236
+ const extensions = parseExtensions(frontmatter.extensions);
237
+ const skills = parseExtensions(frontmatter.skills);
238
+
239
+ // enabled field
240
+ const enabledRaw = frontmatter.enabled;
241
+ let enabled: boolean | undefined;
242
+ if (enabledRaw === "false" || enabledRaw === false) {
243
+ enabled = false;
244
+ } else if (enabledRaw === "true" || enabledRaw === true) {
245
+ enabled = true;
246
+ }
247
+
248
+ // max_turns field
249
+ const maxTurnsRaw = frontmatter.max_turns;
250
+ let maxTurns: number | undefined;
251
+ if (typeof maxTurnsRaw === "number") {
252
+ maxTurns = maxTurnsRaw;
253
+ } else if (typeof maxTurnsRaw === "string" && maxTurnsRaw.length > 0) {
254
+ const parsed = Number(maxTurnsRaw);
255
+ if (!Number.isNaN(parsed)) {
256
+ maxTurns = parsed;
257
+ }
258
+ }
259
+
260
+ return {
261
+ name: parseString(frontmatter, "name"),
262
+ display_name: parseString(frontmatter, "display_name"),
263
+ description: parseString(frontmatter, "description"),
264
+ tools: parseStringArray(frontmatter, "tools"),
265
+ extensions,
266
+ skills,
267
+ model: parseString(frontmatter, "model"),
268
+ thinking: validateThinking(parseString(frontmatter, "thinking")),
269
+ max_turns: maxTurns,
270
+ disallowed_tools: parseStringArray(frontmatter, "disallowed_tools"),
271
+ enabled,
272
+ systemPrompt: body,
273
+ source: source,
274
+ };
275
+ }
276
+
277
+ /* ------------------------------------------------------------------ */
278
+ /* scanAgentFilesInDir */
279
+ /* ------------------------------------------------------------------ */
280
+
281
+ /**
282
+ * Scan a directory for .md files and parse them into AgentConfigFromMd[].
283
+ * Returns empty array if directory doesn't exist.
284
+ */
285
+ export async function scanAgentFilesInDir(
286
+ dirPath: string,
287
+ source: "user" | "project" = "user",
288
+ ): Promise<AgentConfigFromMd[]> {
289
+ try {
290
+ await fs.promises.access(dirPath);
291
+ } catch {
292
+ return [];
293
+ }
294
+
295
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
296
+ const mdFiles = entries.filter(
297
+ (e) => e.isFile() && e.name.endsWith(".md"),
298
+ );
299
+
300
+ const agents: AgentConfigFromMd[] = [];
301
+ for (const entry of mdFiles) {
302
+ const filePath = path.join(dirPath, entry.name);
303
+ try {
304
+ const content = await fs.promises.readFile(filePath, "utf-8");
305
+ const info = parseAgentFile(content, entry.name, source);
306
+ if (info.name) {
307
+ agents.push(info);
308
+ }
309
+ } catch {
310
+ // Skip files that can't be read
311
+ }
312
+ }
313
+ return agents;
314
+ }
315
+
316
+ /* ------------------------------------------------------------------ */
317
+ /* mergeAgents */
318
+ /* ------------------------------------------------------------------ */
319
+
320
+ /**
321
+ * Merge default agents with user and project overrides.
322
+ *
323
+ * Per-field merge precedence (highest to lowest):
324
+ * 1. project agents
325
+ * 2. user agents
326
+ * 3. default agents
327
+ *
328
+ * For each field, if a higher-precedence layer sets the field (not undefined),
329
+ * it wins. Otherwise, the lower layer's value is preserved.
330
+ *
331
+ * @param defaults - Map of default agent configs
332
+ * @param userAgents - User-defined agent configs
333
+ * @param projectAgents - Project-specific agent configs
334
+ * @returns Merged Map<string, AgentConfig> keyed by agent name
335
+ */
336
+ export function mergeAgents(
337
+ defaults: Map<string, AgentConfig>,
338
+ userAgents: AgentConfigFromMd[],
339
+ projectAgents: AgentConfigFromMd[],
340
+ ): Map<string, AgentConfig> {
341
+ const result = new Map<string, AgentConfig>();
342
+
343
+ // Start with defaults
344
+ for (const [name, config] of defaults) {
345
+ result.set(name, { ...config });
346
+ }
347
+
348
+ // Apply user overrides (middle priority), then project (highest priority)
349
+ mergeAgentOverrides(result, userAgents);
350
+ mergeAgentOverrides(result, projectAgents);
351
+
352
+ return result;
353
+ }
354
+
355
+ /**
356
+ * Apply a list of agent configs onto the result map.
357
+ * Existing agents are merged per-field; new agents are built from scratch.
358
+ */
359
+ function mergeAgentOverrides(
360
+ result: Map<string, AgentConfig>,
361
+ agents: AgentConfigFromMd[],
362
+ ): void {
363
+ for (const md of agents) {
364
+ if (!md.name) continue;
365
+ const existing = result.get(md.name);
366
+ if (existing) {
367
+ result.set(md.name, { ...existing, ...fromMd(md) });
368
+ } else {
369
+ result.set(md.name, { ...BASE_DEFAULTS, ...fromMd(md) });
370
+ }
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Translate AgentConfigFromMd fields to a Partial<AgentConfig> containing
376
+ * only fields that are explicitly set in the frontmatter (not undefined).
377
+ *
378
+ * When merging into an existing AgentConfig, spread this result after the
379
+ * existing config so frontmatter fields override defaults while undefined
380
+ * fields fall through to the existing values.
381
+ */
382
+ function fromMd(md: AgentConfigFromMd): Partial<AgentConfig> {
383
+ const obj: Record<string, unknown> = {
384
+ name: md.name,
385
+ displayName: md.display_name,
386
+ description: md.description,
387
+ builtinToolNames: md.tools,
388
+ extensions: md.extensions,
389
+ skills: md.skills,
390
+ model: md.model,
391
+ thinking: md.thinking,
392
+ maxTurns: md.max_turns,
393
+ disallowedTools: md.disallowed_tools,
394
+ enabled: md.enabled,
395
+ systemPrompt: md.systemPrompt,
396
+ source: md.source === "project" ? "project" : "global",
397
+ };
398
+ return compactDefined(obj) as Partial<AgentConfig>;
399
+ }
400
+
401
+ /**
402
+ * Defaults used when creating a new AgentConfig from a .md file that has
403
+ * no existing default to merge into. Satisfies all required AgentConfig
404
+ * fields.
405
+ */
406
+ const BASE_DEFAULTS: AgentConfig = {
407
+ name: "unknown",
408
+ description: "",
409
+ extensions: true,
410
+ skills: true,
411
+ systemPrompt: "",
412
+ };