@johnnywu/pi-subagents 1.0.1 → 1.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/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ # [1.2.0](https://github.com/jwu/pi-subagents/compare/v1.1.0...v1.2.0) (2026-06-02)
2
+
3
+
4
+ ### Features
5
+
6
+ * agent skills frontmatter support ([e479b0c](https://github.com/jwu/pi-subagents/commit/e479b0cc57c8097a89f73b491c32a099ba56bc77))
7
+ * expose available subagents in prompts ([047035a](https://github.com/jwu/pi-subagents/commit/047035a49e9749dc719a97f2556be9f60551cf97))
8
+
9
+ # [1.1.0](https://github.com/jwu/pi-subagents/compare/v1.0.1...v1.1.0) (2026-05-31)
10
+
11
+
12
+ ### Features
13
+
14
+ * **subagent:** list available agents in system prompt Guidelines ([317ed58](https://github.com/jwu/pi-subagents/commit/317ed5845379d3edd535bc03dab8460b29a82164))
15
+
1
16
  ## [1.0.1](https://github.com/jwu/pi-subagents/compare/v1.0.0...v1.0.1) (2026-05-31)
2
17
 
3
18
 
package/README.md CHANGED
@@ -48,6 +48,23 @@ Or instruct pi to delegate:
48
48
  Run the code-reviewer agent on the last three commits
49
49
  ```
50
50
 
51
+ ### Available subagents in the prompt
52
+
53
+ The extension exposes discovered sub-agents to the model in two places:
54
+
55
+ - The `subagent` tool keeps a one-line prompt guideline:
56
+ `Available subagents: code-reviewer, refactor, test-writer`
57
+ - The system prompt also gets an independent block:
58
+
59
+ ```text
60
+ Available subagents:
61
+ - code-reviewer
62
+ - refactor
63
+ - test-writer
64
+ ```
65
+
66
+ For sub-agents launched with `systemPrompt: replace` or `systemPrompt: append`, the same block is written into the prompt passed via `--system-prompt` or `--append-system-prompt` when that agent has the `subagent` tool.
67
+
51
68
  ## Agent configuration
52
69
 
53
70
  Agents are Markdown files with YAML frontmatter.
@@ -102,6 +119,8 @@ Sub-agents can spawn their own sub-agents (if the `subagent` tool is in their wh
102
119
 
103
120
  **`allowedAgents`** — Whitelist enforced by the parent before spawning. A child process never sees agent names outside its parent's whitelist.
104
121
 
122
+ The available-subagents prompt entries respect the same filtering: parent sessions use the currently visible agents, and child sessions only list agents allowed by their parent.
123
+
105
124
  These are passed via environment variables (`PI_SUBAGENT_DEPTH`, `PI_SUBAGENT_MAX_DEPTH`, `PI_SUBAGENT_ALLOWED`).
106
125
 
107
126
  ## Session storage
@@ -15,6 +15,7 @@ export interface AgentConfig {
15
15
  systemPromptMode: SystemPromptMode;
16
16
  allowedAgents?: string[];
17
17
  maxDepth: number;
18
+ skills?: string[];
18
19
  prompt: string;
19
20
  source: AgentSource;
20
21
  filePath: string;
@@ -117,11 +118,13 @@ function parseAgentFile(content: string, filePath: string, source: AgentSource):
117
118
  }
118
119
 
119
120
  const allowedAgents = splitCsv(data.allowedAgents);
121
+ const skills = splitCsv(data.skills);
120
122
 
121
123
  return {
122
124
  name: data.name,
123
125
  description: data.description,
124
126
  tools: splitCsv(data.tools),
127
+ skills: skills.length > 0 ? skills : undefined,
125
128
  model: data.model || undefined,
126
129
  thinking,
127
130
  systemPromptMode,
@@ -0,0 +1,26 @@
1
+ export type RecursionEnv = Partial<
2
+ Record<'PI_SUBAGENT_ALLOWED' | 'PI_SUBAGENT_DEPTH' | 'PI_SUBAGENT_MAX_DEPTH', string>
3
+ >;
4
+
5
+ export function parseEnvNumber(value: string | undefined): number | undefined {
6
+ if (value === undefined) return undefined;
7
+ const parsed = Number(value);
8
+ return Number.isFinite(parsed) ? parsed : undefined;
9
+ }
10
+
11
+ export function allowedAgentNames(env: RecursionEnv): Set<string> | undefined {
12
+ const raw = env?.PI_SUBAGENT_ALLOWED;
13
+ if (!raw) return undefined;
14
+ return new Set(
15
+ raw
16
+ .split(',')
17
+ .map((name) => name.trim())
18
+ .filter(Boolean),
19
+ );
20
+ }
21
+
22
+ export function isPastMaxDepth(env: RecursionEnv): boolean {
23
+ const depth = parseEnvNumber(env?.PI_SUBAGENT_DEPTH);
24
+ const maxDepth = parseEnvNumber(env?.PI_SUBAGENT_MAX_DEPTH);
25
+ return depth !== undefined && maxDepth !== undefined && depth > maxDepth;
26
+ }
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
2
  import { loadAgentDefinitions } from './agent-loader.ts';
3
+ import { allowedAgentNames, isPastMaxDepth } from './env-utils.ts';
4
+ import { appendAvailableSubagentsBlock } from './subagent-prompt.ts';
3
5
  import { registerSubagentTool } from './subagent-tool.ts';
4
6
 
5
7
  export default async function (pi: ExtensionAPI) {
@@ -9,5 +11,17 @@ export default async function (pi: ExtensionAPI) {
9
11
  console.warn(`[pi-subagents] skipped ${warning.filePath}: ${warning.message}`);
10
12
  }
11
13
 
14
+ const allowed = allowedAgentNames(process.env);
15
+ const agents = allowed
16
+ ? result.agents.filter((candidate) => allowed.has(candidate.name))
17
+ : result.agents;
18
+ const agentNames = agents.map((agent) => agent.name);
19
+
20
+ if (!isPastMaxDepth(process.env) && agentNames.length > 0) {
21
+ pi.on('before_agent_start', (event) => ({
22
+ systemPrompt: appendAvailableSubagentsBlock(event.systemPrompt, agentNames),
23
+ }));
24
+ }
25
+
12
26
  registerSubagentTool(pi, { agents: result.agents });
13
27
  }
@@ -0,0 +1,146 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+
5
+ export interface ResolvedSkill {
6
+ name: string;
7
+ description: string;
8
+ location: string;
9
+ }
10
+
11
+ export interface SkillResolverFs {
12
+ exists(filePath: string): boolean;
13
+ readFile(filePath: string): string;
14
+ }
15
+
16
+ export interface ResolveSkillsOptions {
17
+ cwd: string;
18
+ globalDir?: string;
19
+ fs?: SkillResolverFs;
20
+ }
21
+
22
+ const defaultSkillFs: SkillResolverFs = {
23
+ exists(filePath) {
24
+ return fs.existsSync(filePath);
25
+ },
26
+ readFile(filePath) {
27
+ return fs.readFileSync(filePath, 'utf-8');
28
+ },
29
+ };
30
+
31
+ function defaultGlobalSkillsDir(): string {
32
+ return path.join(os.homedir(), '.pi', 'agent', 'skills');
33
+ }
34
+
35
+ function parseSkillFrontmatter(content: string): { name: string; description: string } | undefined {
36
+ const normalized = content.replace(/\r\n/g, '\n');
37
+ if (!normalized.startsWith('---')) return undefined;
38
+
39
+ const endIndex = normalized.indexOf('\n---', 3);
40
+ if (endIndex === -1) return undefined;
41
+
42
+ const frontmatterBlock = normalized.slice(4, endIndex);
43
+ const lines = frontmatterBlock.split('\n');
44
+ const data: Record<string, string> = {};
45
+
46
+ for (let i = 0; i < lines.length; i++) {
47
+ const trimmed = lines[i].trim();
48
+ if (!trimmed || trimmed.startsWith('#')) continue;
49
+
50
+ // Check if this is a YAML block scalar continuation (| or >)
51
+ if (
52
+ data._currentBlockKey !== undefined &&
53
+ (lines[i].startsWith(' ') || lines[i].startsWith('\t'))
54
+ ) {
55
+ const current = data[data._currentBlockKey] ?? '';
56
+ data[data._currentBlockKey] = current ? `${current} ${trimmed}` : trimmed;
57
+ continue;
58
+ }
59
+ delete data._currentBlockKey;
60
+
61
+ const separator = trimmed.indexOf(':');
62
+ if (separator === -1) continue;
63
+ const key = trimmed.slice(0, separator).trim();
64
+ let value = trimmed.slice(separator + 1).trim();
65
+
66
+ // Handle YAML block scalar indicators (| and >)
67
+ if (value === '|' || value === '>') {
68
+ data._currentBlockKey = key;
69
+ data[key] = '';
70
+ continue;
71
+ }
72
+
73
+ value = value.replace(/^['"]|['"]$/g, '');
74
+ if (key) data[key] = value;
75
+ }
76
+ delete data._currentBlockKey;
77
+
78
+ if (!data.description || !data.description.trim()) return undefined;
79
+ return { name: data.name || '', description: data.description.trim() };
80
+ }
81
+
82
+ function findSkillFile(
83
+ skillName: string,
84
+ cwd: string,
85
+ globalDir: string,
86
+ fileSystem: SkillResolverFs,
87
+ ): string | undefined {
88
+ // Priority 1: Project .agents/skills/<name>/SKILL.md
89
+ const projectAgentsPath = path.join(cwd, '.agents', 'skills', skillName, 'SKILL.md');
90
+ if (fileSystem.exists(projectAgentsPath)) return projectAgentsPath;
91
+
92
+ // Priority 1: Project .pi/skills/<name>/SKILL.md
93
+ const projectPiPath = path.join(cwd, '.pi', 'skills', skillName, 'SKILL.md');
94
+ if (fileSystem.exists(projectPiPath)) return projectPiPath;
95
+
96
+ // Priority 4: Global ~/.pi/agent/skills/<name>/SKILL.md
97
+ const globalPiPath = path.join(globalDir, skillName, 'SKILL.md');
98
+ if (fileSystem.exists(globalPiPath)) return globalPiPath;
99
+
100
+ // Priority 4: Global ~/.agents/skills/<name>/SKILL.md
101
+ const globalAgentsPath = path.join(os.homedir(), '.agents', 'skills', skillName, 'SKILL.md');
102
+ if (fileSystem.exists(globalAgentsPath)) return globalAgentsPath;
103
+
104
+ return undefined;
105
+ }
106
+
107
+ export function resolveSkills(
108
+ skillNames: string[],
109
+ options: ResolveSkillsOptions,
110
+ ): { resolved: ResolvedSkill[]; missing: string[] } {
111
+ const cwd = options.cwd;
112
+ const globalDir = options.globalDir ?? defaultGlobalSkillsDir();
113
+ const fileSystem = options.fs ?? defaultSkillFs;
114
+ const resolved: ResolvedSkill[] = [];
115
+ const missing: string[] = [];
116
+
117
+ for (const name of skillNames) {
118
+ const trimmed = name.trim();
119
+ if (!trimmed) continue;
120
+
121
+ const filePath = findSkillFile(trimmed, cwd, globalDir, fileSystem);
122
+ if (!filePath) {
123
+ missing.push(trimmed);
124
+ continue;
125
+ }
126
+
127
+ try {
128
+ const content = fileSystem.readFile(filePath);
129
+ const frontmatter = parseSkillFrontmatter(content);
130
+ if (!frontmatter) {
131
+ missing.push(trimmed);
132
+ continue;
133
+ }
134
+
135
+ resolved.push({
136
+ name: frontmatter.name || trimmed,
137
+ description: frontmatter.description,
138
+ location: filePath,
139
+ });
140
+ } catch {
141
+ missing.push(trimmed);
142
+ }
143
+ }
144
+
145
+ return { resolved, missing };
146
+ }
@@ -5,6 +5,8 @@ import * as os from 'node:os';
5
5
  import * as path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import type { AgentConfig } from './agent-loader.ts';
8
+ import { formatAvailableSubagentsBlock } from './subagent-prompt.ts';
9
+ import { resolveSkills } from './skill-resolver.ts';
8
10
 
9
11
  export interface AgentUsage {
10
12
  input: number;
@@ -77,6 +79,7 @@ export interface RunSubagentOptions {
77
79
  signal?: AbortSignal;
78
80
  onProgress?: (progress: AgentProgress) => void;
79
81
  depth?: number;
82
+ availableAgents?: string[];
80
83
  tempRoot?: string;
81
84
  outputArchiveDir?: string;
82
85
  agentDir?: string;
@@ -274,6 +277,40 @@ function buildTaskArgument(task: string, taskFilePath: string | undefined): stri
274
277
  return taskFilePath ? `Task: @${taskFilePath}` : `Task: ${task}`;
275
278
  }
276
279
 
280
+ function escapeXml(text: string): string {
281
+ return text
282
+ .replace(/&/g, '&amp;')
283
+ .replace(/</g, '&lt;')
284
+ .replace(/>/g, '&gt;')
285
+ .replace(/"/g, '&quot;')
286
+ .replace(/'/g, '&apos;');
287
+ }
288
+
289
+ function formatSkillsForPrompt(
290
+ skills: Array<{ name: string; description: string; location: string }>,
291
+ ): string {
292
+ if (skills.length === 0) return '';
293
+
294
+ const lines = [
295
+ '\n\nThe following skills provide specialized instructions for specific tasks.',
296
+ "Use the read tool to load a skill's file when the task matches its description.",
297
+ 'When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.',
298
+ '',
299
+ '<available_skills>',
300
+ ];
301
+
302
+ for (const skill of skills) {
303
+ lines.push(' <skill>');
304
+ lines.push(` <name>${escapeXml(skill.name)}</name>`);
305
+ lines.push(` <description>${escapeXml(skill.description)}</description>`);
306
+ lines.push(` <location>${escapeXml(skill.location)}</location>`);
307
+ lines.push(' </skill>');
308
+ }
309
+
310
+ lines.push('</available_skills>');
311
+ return lines.join('\n');
312
+ }
313
+
277
314
  function byteLength(text: string): number {
278
315
  return Buffer.byteLength(text, 'utf8');
279
316
  }
@@ -339,7 +376,29 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
339
376
 
340
377
  try {
341
378
  const promptFilePath = path.join(tempDir, 'system-prompt.md');
342
- await fileSystem.writeFile(promptFilePath, options.agent.prompt);
379
+
380
+ let promptContent = options.agent.prompt;
381
+ if (options.agent.tools.includes('subagent')) {
382
+ const availableSubagentsBlock = formatAvailableSubagentsBlock(
383
+ options.availableAgents ?? options.agent.allowedAgents ?? [],
384
+ );
385
+ if (availableSubagentsBlock) {
386
+ promptContent = `${promptContent.trimEnd()}\n\n${availableSubagentsBlock}`;
387
+ }
388
+ }
389
+
390
+ const skillNames = options.agent.skills;
391
+ if (skillNames && skillNames.length > 0) {
392
+ const { resolved, missing } = resolveSkills(skillNames, { cwd: options.cwd });
393
+ for (const name of missing) {
394
+ console.warn(`[pi-subagents] skill not found: ${name}`);
395
+ }
396
+ const skillInjection = formatSkillsForPrompt(resolved);
397
+ if (skillInjection) {
398
+ promptContent = `${promptContent}${skillInjection}`;
399
+ }
400
+ }
401
+ await fileSystem.writeFile(promptFilePath, promptContent);
343
402
 
344
403
  let taskFilePath: string | undefined;
345
404
  if (options.task.length > TASK_FILE_THRESHOLD) {
@@ -0,0 +1,13 @@
1
+ export function formatAvailableSubagentsBlock(agentNames: string[]): string | undefined {
2
+ const names = [...new Set(agentNames.map((name) => name.trim()).filter(Boolean))].sort();
3
+ if (names.length === 0) return undefined;
4
+
5
+ return ['Available subagents:', ...names.map((name) => `- ${name}`)].join('\n');
6
+ }
7
+
8
+ export function appendAvailableSubagentsBlock(systemPrompt: string, agentNames: string[]): string {
9
+ const block = formatAvailableSubagentsBlock(agentNames);
10
+ if (!block || systemPrompt.includes(block)) return systemPrompt;
11
+
12
+ return `${systemPrompt.trimEnd()}\n\n${block}`;
13
+ }
@@ -17,6 +17,7 @@ import {
17
17
  type SubagentResultLine,
18
18
  } from './subagent-render.ts';
19
19
  import { numberArg, preview, shortenPath, stringArg } from './tool-args.ts';
20
+ import { allowedAgentNames, isPastMaxDepth, type RecursionEnv } from './env-utils.ts';
20
21
 
21
22
  const SubagentParams = {
22
23
  type: 'object',
@@ -36,10 +37,6 @@ type SubagentParamsType = {
36
37
  };
37
38
 
38
39
  type RegisterablePi = Pick<ExtensionAPI, 'registerTool'>;
39
- type RecursionEnv = Partial<
40
- Record<'PI_SUBAGENT_ALLOWED' | 'PI_SUBAGENT_DEPTH' | 'PI_SUBAGENT_MAX_DEPTH', string>
41
- >;
42
-
43
40
  export interface RegisterSubagentToolOptions {
44
41
  agents: AgentConfig[];
45
42
  run?: typeof runSubagent;
@@ -70,29 +67,6 @@ function toProgressResult(progress: AgentProgress) {
70
67
  };
71
68
  }
72
69
 
73
- function parseEnvNumber(value: string | undefined): number | undefined {
74
- if (value === undefined) return undefined;
75
- const parsed = Number(value);
76
- return Number.isFinite(parsed) ? parsed : undefined;
77
- }
78
-
79
- function allowedAgentNames(env: RecursionEnv): Set<string> | undefined {
80
- const raw = env?.PI_SUBAGENT_ALLOWED;
81
- if (!raw) return undefined;
82
- return new Set(
83
- raw
84
- .split(',')
85
- .map((name) => name.trim())
86
- .filter(Boolean),
87
- );
88
- }
89
-
90
- function isPastMaxDepth(env: RecursionEnv): boolean {
91
- const depth = parseEnvNumber(env?.PI_SUBAGENT_DEPTH);
92
- const maxDepth = parseEnvNumber(env?.PI_SUBAGENT_MAX_DEPTH);
93
- return depth !== undefined && maxDepth !== undefined && depth > maxDepth;
94
- }
95
-
96
70
  type CollapsedTheme = {
97
71
  fg: (name: ThemeColor, text: string) => string;
98
72
  bold: (text: string) => string;
@@ -243,11 +217,17 @@ export function registerSubagentTool(
243
217
  : options.agents;
244
218
  const runner = options.run ?? runSubagent;
245
219
 
220
+ const availableSubagents = agents.map((agent) => agent.name);
221
+ const agentNames = [...availableSubagents].sort().join(', ');
222
+ const promptGuidelines =
223
+ agentNames.length > 0 ? [`Available subagents: ${agentNames}`] : undefined;
224
+
246
225
  pi.registerTool({
247
226
  name: 'subagent',
248
227
  label: 'Subagent',
249
228
  description: 'Delegate a task to a named sub-agent running in an isolated pi process.',
250
229
  promptSnippet: 'Delegate isolated tasks with subagent({ agent, task, cwd? }).',
230
+ promptGuidelines,
251
231
  parameters: SubagentParams,
252
232
 
253
233
  async execute(
@@ -270,6 +250,7 @@ export function registerSubagentTool(
270
250
  cwd: params.cwd ?? ctx.cwd,
271
251
  signal,
272
252
  depth: Number(env.PI_SUBAGENT_DEPTH ?? '0') + 1,
253
+ availableAgents: availableSubagents,
273
254
  onProgress: (progress) => onUpdate?.(toProgressResult(progress)),
274
255
  });
275
256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnnywu/pi-subagents",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Sub-agents extension for pi coding agent.",
5
5
  "homepage": "https://github.com/jwu/pi-subagents#readme",
6
6
  "repository": {