@johnnywu/pi-subagents 1.1.0 → 1.3.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,27 @@
1
+ # [1.3.0](https://github.com/jwu/pi-subagents/compare/v1.2.0...v1.3.0) (2026-06-02)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * filter available subagents by allowed agents ([adc6025](https://github.com/jwu/pi-subagents/commit/adc60252d96ef532719a0da1c128a8e433c74bb7))
7
+ * preserve append-mode subagent context ([ada0a26](https://github.com/jwu/pi-subagents/commit/ada0a264565d686917b73126301884345f824bce))
8
+
9
+
10
+ ### Features
11
+
12
+ * add debug subagent prompt command ([1c49d43](https://github.com/jwu/pi-subagents/commit/1c49d43af9569856407e2a97d512d9d3b617e6d9))
13
+ * default subagent prompts to append ([5693c17](https://github.com/jwu/pi-subagents/commit/5693c17b2aea4244b106bff82fe7ebd5c954322c))
14
+ * export subagent debug prompts from frontmatter ([afe8b23](https://github.com/jwu/pi-subagents/commit/afe8b23700b9a86ef1db97463225cd828f884026))
15
+ * resolve package skills for subagents ([48651e1](https://github.com/jwu/pi-subagents/commit/48651e15010c501cacec9a2e71771a13c0e7c9ab))
16
+
17
+ # [1.2.0](https://github.com/jwu/pi-subagents/compare/v1.1.0...v1.2.0) (2026-06-02)
18
+
19
+
20
+ ### Features
21
+
22
+ * agent skills frontmatter support ([e479b0c](https://github.com/jwu/pi-subagents/commit/e479b0cc57c8097a89f73b491c32a099ba56bc77))
23
+ * expose available subagents in prompts ([047035a](https://github.com/jwu/pi-subagents/commit/047035a49e9749dc719a97f2556be9f60551cf97))
24
+
1
25
  # [1.1.0](https://github.com/jwu/pi-subagents/compare/v1.0.1...v1.1.0) (2026-05-31)
2
26
 
3
27
 
package/README.md CHANGED
@@ -48,6 +48,41 @@ 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 when the active tool set includes `subagent`:
54
+
55
+ - The `subagent` tool keeps a one-line prompt guideline:
56
+ `Available subagents: code-reviewer, refactor, test-writer`
57
+ - At agent-start time, the system prompt gets an independent block:
58
+
59
+ ```text
60
+ Available subagents:
61
+ - code-reviewer
62
+ - refactor
63
+ - test-writer
64
+ ```
65
+
66
+ The prompt file passed to the child process contains only the agent prompt plus skills. Runtime prompt assembly then depends on `systemPrompt` mode:
67
+
68
+ - `append`: the child process uses pi's default prompt and project context files, then appends the agent prompt.
69
+ - `replace`: the child process replaces pi's default prompt with the agent prompt and skips project context files; pi-subagents reinjects the active `Available tools` and `Guidelines` blocks at agent-start time so custom replace prompts still expose the selected tool affordances.
70
+
71
+ The `Available subagents` block is injected by the child process at agent-start time, after `PI_SUBAGENT_ALLOWED` and recursion depth filtering are applied.
72
+
73
+ ### Debug a sub-agent prompt
74
+
75
+ Set `debug: true` in an agent's frontmatter to export that sub-agent's effective runtime system prompt on each run:
76
+
77
+ ```markdown
78
+ ---
79
+ name: scout
80
+ debug: true
81
+ ---
82
+ ```
83
+
84
+ The child process writes `debug-system-prompt.md` in the project cwd. The file contains the prompt visible during `before_agent_start`, including `systemPrompt` append/replace behavior, tools/guidelines, skills, project context files in append mode, and pi-subagents' runtime `Available subagents` block when applicable.
85
+
51
86
  ## Agent configuration
52
87
 
53
88
  Agents are Markdown files with YAML frontmatter.
@@ -59,9 +94,10 @@ Agents are Markdown files with YAML frontmatter.
59
94
  | `tools` | no | _none_ | Comma-separated tool whitelist (`read, write, bash, grep`, etc.) |
60
95
  | `model` | no | parent's model | Provider/model-id (`anthropic/claude-sonnet-4-6`) |
61
96
  | `thinking` | no | `off` | Reasoning level: `off`, `low`, `medium`, `high` |
62
- | `systemPrompt` | no | `replace` | How the body is applied: `replace` (default system prompt) or `append` |
97
+ | `systemPrompt` | no | `append` | How the body is applied: `append` (append to pi default system prompt and project context) or `replace` (replace default prompt and skip project context) |
63
98
  | `allowedAgents` | no | _all_ | Comma-separated list of sub-agents this agent may spawn |
64
99
  | `maxDepth` | no | `10` | Maximum recursion depth (`0` = no sub-agents, `1` = one level, etc.) |
100
+ | `debug` | no | `false` | When `true`, export the effective runtime system prompt to `debug-system-prompt.md` |
65
101
 
66
102
  The Markdown body after the frontmatter is the agent's system prompt.
67
103
 
@@ -74,9 +110,10 @@ description: High-level planner that delegates to specialists
74
110
  tools: subagent, read, grep, find
75
111
  model: anthropic/claude-sonnet-4-6
76
112
  thinking: high
77
- systemPrompt: replace
113
+ systemPrompt: append
78
114
  allowedAgents: code-reviewer, refactor, test-writer
79
115
  maxDepth: 2
116
+ debug: false
80
117
  ---
81
118
 
82
119
  You are an orchestrator. Break complex tasks into sub-tasks and delegate
@@ -102,7 +139,9 @@ Sub-agents can spawn their own sub-agents (if the `subagent` tool is in their wh
102
139
 
103
140
  **`allowedAgents`** — Whitelist enforced by the parent before spawning. A child process never sees agent names outside its parent's whitelist.
104
141
 
105
- These are passed via environment variables (`PI_SUBAGENT_DEPTH`, `PI_SUBAGENT_MAX_DEPTH`, `PI_SUBAGENT_ALLOWED`).
142
+ 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.
143
+
144
+ These are passed via environment variables (`PI_SUBAGENT_DEPTH`, `PI_SUBAGENT_MAX_DEPTH`, `PI_SUBAGENT_ALLOWED`). Child processes also receive `PI_SUBAGENT_NAME` and `PI_SUBAGENT_SYSTEM_PROMPT_MODE` so runtime hooks can distinguish append vs. replace behavior.
106
145
 
107
146
  ## Session storage
108
147
 
@@ -15,6 +15,8 @@ export interface AgentConfig {
15
15
  systemPromptMode: SystemPromptMode;
16
16
  allowedAgents?: string[];
17
17
  maxDepth: number;
18
+ debug: boolean;
19
+ skills?: string[];
18
20
  prompt: string;
19
21
  source: AgentSource;
20
22
  filePath: string;
@@ -106,7 +108,7 @@ function parseAgentFile(content: string, filePath: string, source: AgentSource):
106
108
  throw new Error(`invalid thinking: ${data.thinking}`);
107
109
  }
108
110
 
109
- const systemPromptMode = (data.systemPrompt ?? 'replace') as SystemPromptMode;
111
+ const systemPromptMode = (data.systemPrompt ?? 'append') as SystemPromptMode;
110
112
  if (!['replace', 'append'].includes(systemPromptMode)) {
111
113
  throw new Error(`invalid systemPrompt: ${data.systemPrompt}`);
112
114
  }
@@ -116,17 +118,28 @@ function parseAgentFile(content: string, filePath: string, source: AgentSource):
116
118
  throw new Error(`invalid maxDepth: ${data.maxDepth}`);
117
119
  }
118
120
 
121
+ let debug = false;
122
+ if (data.debug !== undefined) {
123
+ if (data.debug !== 'true' && data.debug !== 'false') {
124
+ throw new Error(`invalid debug: ${data.debug}`);
125
+ }
126
+ debug = data.debug === 'true';
127
+ }
128
+
119
129
  const allowedAgents = splitCsv(data.allowedAgents);
130
+ const skills = splitCsv(data.skills);
120
131
 
121
132
  return {
122
133
  name: data.name,
123
134
  description: data.description,
124
135
  tools: splitCsv(data.tools),
136
+ skills: skills.length > 0 ? skills : undefined,
125
137
  model: data.model || undefined,
126
138
  thinking,
127
139
  systemPromptMode,
128
140
  allowedAgents: allowedAgents.length > 0 ? allowedAgents : undefined,
129
141
  maxDepth,
142
+ debug,
130
143
  prompt: body,
131
144
  source,
132
145
  filePath,
@@ -1,5 +1,14 @@
1
+ export type SystemPromptModeEnv = 'replace' | 'append';
2
+
1
3
  export type RecursionEnv = Partial<
2
- Record<'PI_SUBAGENT_ALLOWED' | 'PI_SUBAGENT_DEPTH' | 'PI_SUBAGENT_MAX_DEPTH', string>
4
+ Record<
5
+ | 'PI_SUBAGENT_ALLOWED'
6
+ | 'PI_SUBAGENT_DEPTH'
7
+ | 'PI_SUBAGENT_MAX_DEPTH'
8
+ | 'PI_SUBAGENT_NAME'
9
+ | 'PI_SUBAGENT_SYSTEM_PROMPT_MODE',
10
+ string
11
+ >
3
12
  >;
4
13
 
5
14
  export function parseEnvNumber(value: string | undefined): number | undefined {
@@ -24,3 +33,18 @@ export function isPastMaxDepth(env: RecursionEnv): boolean {
24
33
  const maxDepth = parseEnvNumber(env?.PI_SUBAGENT_MAX_DEPTH);
25
34
  return depth !== undefined && maxDepth !== undefined && depth > maxDepth;
26
35
  }
36
+
37
+ export function isSubagentProcess(env: RecursionEnv): boolean {
38
+ const depth = parseEnvNumber(env?.PI_SUBAGENT_DEPTH);
39
+ return depth !== undefined && depth > 0;
40
+ }
41
+
42
+ export function subagentSystemPromptMode(env: RecursionEnv): SystemPromptModeEnv | undefined {
43
+ const mode = env?.PI_SUBAGENT_SYSTEM_PROMPT_MODE;
44
+ if (mode === 'replace' || mode === 'append') return mode;
45
+ return undefined;
46
+ }
47
+
48
+ export function isSubagentReplaceSystemPrompt(env: RecursionEnv): boolean {
49
+ return isSubagentProcess(env) && subagentSystemPromptMode(env) === 'replace';
50
+ }
@@ -1,5 +1,12 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
2
4
  import { loadAgentDefinitions } from './agent-loader.ts';
5
+ import { allowedAgentNames, isPastMaxDepth, isSubagentReplaceSystemPrompt } from './env-utils.ts';
6
+ import {
7
+ appendAvailableSubagentsBlock,
8
+ appendAvailableToolsAndGuidelinesBlock,
9
+ } from './subagent-prompt.ts';
3
10
  import { registerSubagentTool } from './subagent-tool.ts';
4
11
 
5
12
  export default async function (pi: ExtensionAPI) {
@@ -9,5 +16,39 @@ export default async function (pi: ExtensionAPI) {
9
16
  console.warn(`[pi-subagents] skipped ${warning.filePath}: ${warning.message}`);
10
17
  }
11
18
 
19
+ const allowed = allowedAgentNames(process.env);
20
+ const agents = allowed
21
+ ? result.agents.filter((candidate) => allowed.has(candidate.name))
22
+ : result.agents;
23
+ const agentNames = agents.map((agent) => agent.name);
24
+
25
+ if (isSubagentReplaceSystemPrompt(process.env)) {
26
+ pi.on('before_agent_start', (event) => {
27
+ return {
28
+ systemPrompt: appendAvailableToolsAndGuidelinesBlock(
29
+ event.systemPrompt,
30
+ event.systemPromptOptions,
31
+ ),
32
+ };
33
+ });
34
+ }
35
+
36
+ if (!isPastMaxDepth(process.env) && agentNames.length > 0) {
37
+ pi.on('before_agent_start', (event) => {
38
+ if (!event.systemPromptOptions.selectedTools?.includes('subagent')) return;
39
+ return {
40
+ systemPrompt: appendAvailableSubagentsBlock(event.systemPrompt, agentNames),
41
+ };
42
+ });
43
+ }
44
+
45
+ if (process.env.PI_SUBAGENT_DEBUG === 'true') {
46
+ pi.on('before_agent_start', (_event, ctx) => {
47
+ const prompt = ctx.getSystemPrompt();
48
+ const outputPath = path.join(ctx.cwd, 'debug-system-prompt.md');
49
+ fs.writeFileSync(outputPath, prompt, 'utf-8');
50
+ });
51
+ }
52
+
12
53
  registerSubagentTool(pi, { agents: result.agents });
13
54
  }
@@ -0,0 +1,228 @@
1
+ import {
2
+ DefaultPackageManager,
3
+ SettingsManager,
4
+ getAgentDir,
5
+ type ResolvedResource,
6
+ } from '@earendil-works/pi-coding-agent';
7
+ import * as fs from 'node:fs';
8
+ import * as os from 'node:os';
9
+ import * as path from 'node:path';
10
+
11
+ export interface ResolvedSkill {
12
+ name: string;
13
+ description: string;
14
+ location: string;
15
+ }
16
+
17
+ export interface SkillResolverFs {
18
+ exists(filePath: string): boolean;
19
+ readFile(filePath: string): string;
20
+ }
21
+
22
+ export interface ResolveSkillsOptions {
23
+ cwd: string;
24
+ agentDir?: string;
25
+ globalDir?: string;
26
+ fs?: SkillResolverFs;
27
+ packageSkillFiles?: string[];
28
+ }
29
+
30
+ export interface ResolveSkillsResult {
31
+ resolved: ResolvedSkill[];
32
+ missing: string[];
33
+ skippedPackages: string[];
34
+ }
35
+
36
+ const defaultSkillFs: SkillResolverFs = {
37
+ exists(filePath) {
38
+ return fs.existsSync(filePath);
39
+ },
40
+ readFile(filePath) {
41
+ return fs.readFileSync(filePath, 'utf-8');
42
+ },
43
+ };
44
+
45
+ function defaultGlobalSkillsDir(): string {
46
+ return path.join(os.homedir(), '.pi', 'agent', 'skills');
47
+ }
48
+
49
+ function parseSkillFrontmatter(content: string): { name: string; description: string } | undefined {
50
+ const normalized = content.replace(/\r\n/g, '\n');
51
+ if (!normalized.startsWith('---')) return undefined;
52
+
53
+ const endIndex = normalized.indexOf('\n---', 3);
54
+ if (endIndex === -1) return undefined;
55
+
56
+ const frontmatterBlock = normalized.slice(4, endIndex);
57
+ const lines = frontmatterBlock.split('\n');
58
+ const data: Record<string, string> = {};
59
+
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const trimmed = lines[i].trim();
62
+ if (!trimmed || trimmed.startsWith('#')) continue;
63
+
64
+ // Check if this is a YAML block scalar continuation (| or >)
65
+ if (
66
+ data._currentBlockKey !== undefined &&
67
+ (lines[i].startsWith(' ') || lines[i].startsWith('\t'))
68
+ ) {
69
+ const current = data[data._currentBlockKey] ?? '';
70
+ data[data._currentBlockKey] = current ? `${current} ${trimmed}` : trimmed;
71
+ continue;
72
+ }
73
+ delete data._currentBlockKey;
74
+
75
+ const separator = trimmed.indexOf(':');
76
+ if (separator === -1) continue;
77
+ const key = trimmed.slice(0, separator).trim();
78
+ let value = trimmed.slice(separator + 1).trim();
79
+
80
+ // Handle YAML block scalar indicators (| and >)
81
+ if (value === '|' || value === '>') {
82
+ data._currentBlockKey = key;
83
+ data[key] = '';
84
+ continue;
85
+ }
86
+
87
+ value = value.replace(/^['"]|['"]$/g, '');
88
+ if (key) data[key] = value;
89
+ }
90
+ delete data._currentBlockKey;
91
+
92
+ if (!data.description || !data.description.trim()) return undefined;
93
+ return { name: data.name || '', description: data.description.trim() };
94
+ }
95
+
96
+ function findLocalSkillFile(
97
+ skillName: string,
98
+ cwd: string,
99
+ globalDir: string,
100
+ fileSystem: SkillResolverFs,
101
+ ): string | undefined {
102
+ // Priority 1: Project .agents/skills/<name>/SKILL.md
103
+ const projectAgentsPath = path.join(cwd, '.agents', 'skills', skillName, 'SKILL.md');
104
+ if (fileSystem.exists(projectAgentsPath)) return projectAgentsPath;
105
+
106
+ // Priority 1: Project .pi/skills/<name>/SKILL.md
107
+ const projectPiPath = path.join(cwd, '.pi', 'skills', skillName, 'SKILL.md');
108
+ if (fileSystem.exists(projectPiPath)) return projectPiPath;
109
+
110
+ // Priority 4: Global ~/.pi/agent/skills/<name>/SKILL.md
111
+ const globalPiPath = path.join(globalDir, skillName, 'SKILL.md');
112
+ if (fileSystem.exists(globalPiPath)) return globalPiPath;
113
+
114
+ // Priority 4: Global ~/.agents/skills/<name>/SKILL.md
115
+ const globalAgentsPath = path.join(os.homedir(), '.agents', 'skills', skillName, 'SKILL.md');
116
+ if (fileSystem.exists(globalAgentsPath)) return globalAgentsPath;
117
+
118
+ return undefined;
119
+ }
120
+
121
+ async function resolvePackageSkillFiles(options: ResolveSkillsOptions): Promise<{
122
+ files: string[];
123
+ skippedPackages: string[];
124
+ }> {
125
+ if (options.packageSkillFiles) {
126
+ return { files: options.packageSkillFiles, skippedPackages: [] };
127
+ }
128
+ if (options.fs) {
129
+ return { files: [], skippedPackages: [] };
130
+ }
131
+
132
+ const skippedPackages: string[] = [];
133
+ const agentDir = options.agentDir ?? getAgentDir();
134
+ const settingsManager = SettingsManager.create(options.cwd, agentDir);
135
+ const packageManager = new DefaultPackageManager({
136
+ cwd: options.cwd,
137
+ agentDir,
138
+ settingsManager,
139
+ });
140
+ const resolvedPaths = await packageManager.resolve(async (source) => {
141
+ skippedPackages.push(source);
142
+ return 'skip';
143
+ });
144
+
145
+ return {
146
+ files: resolvedPaths.skills
147
+ .filter((resource: ResolvedResource) => resource.enabled)
148
+ .map((resource: ResolvedResource) => resource.path),
149
+ skippedPackages,
150
+ };
151
+ }
152
+
153
+ function readSkill(
154
+ filePath: string,
155
+ requestedName: string,
156
+ fileSystem: SkillResolverFs,
157
+ requireNameMatch: boolean,
158
+ ): ResolvedSkill | undefined {
159
+ const content = fileSystem.readFile(filePath);
160
+ const frontmatter = parseSkillFrontmatter(content);
161
+ if (!frontmatter) return undefined;
162
+
163
+ const fileSkillName = frontmatter.name || path.basename(path.dirname(filePath));
164
+ const markdownName = path.basename(filePath, '.md');
165
+ if (requireNameMatch && fileSkillName !== requestedName && markdownName !== requestedName) {
166
+ return undefined;
167
+ }
168
+
169
+ return {
170
+ name: fileSkillName || requestedName,
171
+ description: frontmatter.description,
172
+ location: filePath,
173
+ };
174
+ }
175
+
176
+ export async function resolveSkills(
177
+ skillNames: string[],
178
+ options: ResolveSkillsOptions,
179
+ ): Promise<ResolveSkillsResult> {
180
+ const cwd = options.cwd;
181
+ const globalDir = options.globalDir ?? defaultGlobalSkillsDir();
182
+ const fileSystem = options.fs ?? defaultSkillFs;
183
+ const resolved: ResolvedSkill[] = [];
184
+ const missing: string[] = [];
185
+ const pendingPackageResolution: string[] = [];
186
+
187
+ for (const name of skillNames) {
188
+ const trimmed = name.trim();
189
+ if (!trimmed) continue;
190
+
191
+ const localFilePath = findLocalSkillFile(trimmed, cwd, globalDir, fileSystem);
192
+ if (localFilePath) {
193
+ try {
194
+ const skill = readSkill(localFilePath, trimmed, fileSystem, false);
195
+ if (skill) {
196
+ resolved.push(skill);
197
+ continue;
198
+ }
199
+ } catch {
200
+ // Try package skills before marking the skill as missing.
201
+ }
202
+ }
203
+
204
+ pendingPackageResolution.push(trimmed);
205
+ }
206
+
207
+ const packageSkills =
208
+ pendingPackageResolution.length > 0
209
+ ? await resolvePackageSkillFiles(options)
210
+ : { files: [], skippedPackages: [] };
211
+
212
+ for (const trimmed of pendingPackageResolution) {
213
+ let packageSkill: ResolvedSkill | undefined;
214
+ for (const filePath of packageSkills.files) {
215
+ try {
216
+ packageSkill = readSkill(filePath, trimmed, fileSystem, true);
217
+ if (packageSkill) break;
218
+ } catch {
219
+ // Try the next package skill file.
220
+ }
221
+ }
222
+
223
+ if (packageSkill) resolved.push(packageSkill);
224
+ else missing.push(trimmed);
225
+ }
226
+
227
+ return { resolved, missing, skippedPackages: packageSkills.skippedPackages };
228
+ }
@@ -5,6 +5,7 @@ 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 { resolveSkills } from './skill-resolver.ts';
8
9
 
9
10
  export interface AgentUsage {
10
11
  input: number;
@@ -77,6 +78,7 @@ export interface RunSubagentOptions {
77
78
  signal?: AbortSignal;
78
79
  onProgress?: (progress: AgentProgress) => void;
79
80
  depth?: number;
81
+ availableAgents?: string[];
80
82
  tempRoot?: string;
81
83
  outputArchiveDir?: string;
82
84
  agentDir?: string;
@@ -86,10 +88,38 @@ export interface RunSubagentOptions {
86
88
  now?: () => number;
87
89
  }
88
90
 
91
+ export interface BuildSubagentSystemPromptOptions {
92
+ agent: AgentConfig;
93
+ cwd: string;
94
+ agentDir?: string;
95
+ }
96
+
97
+ export interface BuildSubagentSystemPromptResult {
98
+ prompt: string;
99
+ missingSkills: string[];
100
+ skippedSkillPackages: string[];
101
+ }
102
+
89
103
  const TASK_FILE_THRESHOLD = 8000;
90
104
  const OUTPUT_MAX_BYTES = 50 * 1024;
91
105
  const OUTPUT_MAX_LINES = 2000;
92
106
 
107
+ export function availableSubagentsForAgent(
108
+ agent: AgentConfig,
109
+ candidateNames?: string[],
110
+ ): string[] {
111
+ const names = candidateNames ?? agent.allowedAgents ?? [];
112
+ const allowed = agent.allowedAgents ? new Set(agent.allowedAgents) : undefined;
113
+ const seen = new Set<string>();
114
+
115
+ return names.filter((name) => {
116
+ if (seen.has(name)) return false;
117
+ if (allowed && !allowed.has(name)) return false;
118
+ seen.add(name);
119
+ return true;
120
+ });
121
+ }
122
+
93
123
  const defaultFs: ExecutorFs = {
94
124
  makeTempDir(prefix) {
95
125
  return fs.mkdtemp(prefix);
@@ -274,6 +304,40 @@ function buildTaskArgument(task: string, taskFilePath: string | undefined): stri
274
304
  return taskFilePath ? `Task: @${taskFilePath}` : `Task: ${task}`;
275
305
  }
276
306
 
307
+ function escapeXml(text: string): string {
308
+ return text
309
+ .replace(/&/g, '&amp;')
310
+ .replace(/</g, '&lt;')
311
+ .replace(/>/g, '&gt;')
312
+ .replace(/"/g, '&quot;')
313
+ .replace(/'/g, '&apos;');
314
+ }
315
+
316
+ function formatSkillsForPrompt(
317
+ skills: Array<{ name: string; description: string; location: string }>,
318
+ ): string {
319
+ if (skills.length === 0) return '';
320
+
321
+ const lines = [
322
+ '\n\nThe following skills provide specialized instructions for specific tasks.',
323
+ "Use the read tool to load a skill's file when the task matches its description.",
324
+ '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.',
325
+ '',
326
+ '<available_skills>',
327
+ ];
328
+
329
+ for (const skill of skills) {
330
+ lines.push(' <skill>');
331
+ lines.push(` <name>${escapeXml(skill.name)}</name>`);
332
+ lines.push(` <description>${escapeXml(skill.description)}</description>`);
333
+ lines.push(` <location>${escapeXml(skill.location)}</location>`);
334
+ lines.push(' </skill>');
335
+ }
336
+
337
+ lines.push('</available_skills>');
338
+ return lines.join('\n');
339
+ }
340
+
277
341
  function byteLength(text: string): number {
278
342
  return Buffer.byteLength(text, 'utf8');
279
343
  }
@@ -296,6 +360,31 @@ function safeFilePart(value: string): string {
296
360
  return value.replace(/[^a-zA-Z0-9_.-]+/g, '_');
297
361
  }
298
362
 
363
+ export async function buildSubagentSystemPrompt(
364
+ options: BuildSubagentSystemPromptOptions,
365
+ ): Promise<BuildSubagentSystemPromptResult> {
366
+ let prompt = options.agent.prompt;
367
+
368
+ const missingSkills: string[] = [];
369
+ const skippedSkillPackages: string[] = [];
370
+ const skillNames = options.agent.skills;
371
+ if (skillNames && skillNames.length > 0) {
372
+ const resolvedSkills = await resolveSkills(skillNames, {
373
+ cwd: options.cwd,
374
+ agentDir: options.agentDir,
375
+ });
376
+ missingSkills.push(...resolvedSkills.missing);
377
+ skippedSkillPackages.push(...resolvedSkills.skippedPackages);
378
+
379
+ const skillInjection = formatSkillsForPrompt(resolvedSkills.resolved);
380
+ if (skillInjection) {
381
+ prompt = `${prompt}${skillInjection}`;
382
+ }
383
+ }
384
+
385
+ return { prompt, missingSkills, skippedSkillPackages };
386
+ }
387
+
299
388
  function progressFromPartialResult(partialResult: unknown): AgentProgress | undefined {
300
389
  if (!partialResult || typeof partialResult !== 'object') return undefined;
301
390
  const details = (partialResult as { details?: unknown }).details;
@@ -339,7 +428,19 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
339
428
 
340
429
  try {
341
430
  const promptFilePath = path.join(tempDir, 'system-prompt.md');
342
- await fileSystem.writeFile(promptFilePath, options.agent.prompt);
431
+
432
+ const promptResult = await buildSubagentSystemPrompt({
433
+ agent: options.agent,
434
+ cwd: options.cwd,
435
+ agentDir: options.agentDir,
436
+ });
437
+ for (const source of promptResult.skippedSkillPackages) {
438
+ console.warn(`[pi-subagents] package not installed, skipping skills: ${source}`);
439
+ }
440
+ for (const name of promptResult.missingSkills) {
441
+ console.warn(`[pi-subagents] skill not found: ${name}`);
442
+ }
443
+ await fileSystem.writeFile(promptFilePath, promptResult.prompt);
343
444
 
344
445
  let taskFilePath: string | undefined;
345
446
  if (options.task.length > TASK_FILE_THRESHOLD) {
@@ -352,16 +453,9 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
352
453
  AuthStorage.create(options.agentDir ? path.join(options.agentDir, 'auth.json') : undefined),
353
454
  options.agentDir ? path.join(options.agentDir, 'models.json') : undefined,
354
455
  );
355
- const args = [
356
- pi.entryPoint,
357
- '--mode',
358
- 'json',
359
- '-p',
360
- '--no-skills',
361
- '--no-prompt-templates',
362
- '--no-context-files',
363
- ];
456
+ const args = [pi.entryPoint, '--mode', 'json', '-p', '--no-skills', '--no-prompt-templates'];
364
457
 
458
+ if (options.agent.systemPromptMode === 'replace') args.push('--no-context-files');
365
459
  if (options.agent.model) args.push('--model', options.agent.model);
366
460
  args.push('--thinking', options.agent.thinking);
367
461
  if (options.agent.tools.length > 0) args.push('--tools', options.agent.tools.join(','));
@@ -376,10 +470,14 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
376
470
  ...process.env,
377
471
  PI_SUBAGENT_DEPTH: String(options.depth ?? 1),
378
472
  PI_SUBAGENT_MAX_DEPTH: String(options.agent.maxDepth),
473
+ PI_SUBAGENT_NAME: options.agent.name,
474
+ PI_SUBAGENT_SYSTEM_PROMPT_MODE: options.agent.systemPromptMode,
379
475
  };
380
- if (options.agent.tools.includes('subagent') && options.agent.allowedAgents?.length) {
381
- env.PI_SUBAGENT_ALLOWED = options.agent.allowedAgents.join(',');
476
+ const visibleAgents = availableSubagentsForAgent(options.agent, options.availableAgents);
477
+ if (visibleAgents.length > 0) {
478
+ env.PI_SUBAGENT_ALLOWED = visibleAgents.join(',');
382
479
  }
480
+ env.PI_SUBAGENT_DEBUG = options.agent.debug ? 'true' : 'false';
383
481
 
384
482
  const processLine = (line: string) => {
385
483
  if (!line.trim()) return;
@@ -0,0 +1,87 @@
1
+ export interface ToolGuidelinePromptOptions {
2
+ selectedTools?: string[];
3
+ toolSnippets?: Record<string, string>;
4
+ promptGuidelines?: string[];
5
+ }
6
+
7
+ function uniqueNonEmpty(values: string[]): string[] {
8
+ const seen = new Set<string>();
9
+ const result: string[] = [];
10
+ for (const value of values) {
11
+ const normalized = value.trim();
12
+ if (!normalized || seen.has(normalized)) continue;
13
+ seen.add(normalized);
14
+ result.push(normalized);
15
+ }
16
+ return result;
17
+ }
18
+
19
+ export function formatAvailableToolsAndGuidelinesBlock(
20
+ options: ToolGuidelinePromptOptions,
21
+ ): string | undefined {
22
+ const selectedTools = options.selectedTools ?? ['read', 'bash', 'edit', 'write'];
23
+ const visibleTools = selectedTools.filter((name) => options.toolSnippets?.[name]);
24
+ const toolsList =
25
+ visibleTools.length > 0
26
+ ? visibleTools.map((name) => `- ${name}: ${options.toolSnippets![name]}`).join('\n')
27
+ : '(none)';
28
+
29
+ const hasBashOnlyForFileExploration =
30
+ selectedTools.includes('bash') &&
31
+ !selectedTools.includes('grep') &&
32
+ !selectedTools.includes('find') &&
33
+ !selectedTools.includes('ls');
34
+ const guidelines = uniqueNonEmpty([
35
+ ...(hasBashOnlyForFileExploration ? ['Use bash for file operations like ls, rg, find'] : []),
36
+ ...(options.promptGuidelines ?? []),
37
+ 'Be concise in your responses',
38
+ 'Show file paths clearly when working with files',
39
+ ]);
40
+ const guidelineLines = guidelines.map((guideline) => `- ${guideline}`).join('\n');
41
+
42
+ return [
43
+ 'Available tools:',
44
+ toolsList,
45
+ '',
46
+ 'In addition to the tools above, you may have access to other custom tools depending on the project.',
47
+ '',
48
+ 'Guidelines:',
49
+ guidelineLines || '(none)',
50
+ ].join('\n');
51
+ }
52
+
53
+ function appendBeforeTrailingRuntimeMetadata(systemPrompt: string, block: string): string {
54
+ const marker = '\nCurrent date:';
55
+ const index = systemPrompt.lastIndexOf(marker);
56
+ if (index === -1) return `${systemPrompt.trimEnd()}\n\n${block}`;
57
+
58
+ const before = systemPrompt.slice(0, index).trimEnd();
59
+ const after = systemPrompt.slice(index);
60
+ return `${before}\n\n${block}${after}`;
61
+ }
62
+
63
+ export function appendAvailableToolsAndGuidelinesBlock(
64
+ systemPrompt: string,
65
+ options: ToolGuidelinePromptOptions,
66
+ ): string {
67
+ const block = formatAvailableToolsAndGuidelinesBlock(options);
68
+ if (!block || systemPrompt.includes('Available tools:') || systemPrompt.includes('Guidelines:')) {
69
+ return systemPrompt;
70
+ }
71
+
72
+ return appendBeforeTrailingRuntimeMetadata(systemPrompt, block);
73
+ }
74
+
75
+ export function formatAvailableSubagentsBlock(agentNames: string[]): string | undefined {
76
+ const names = [...new Set(agentNames.map((name) => name.trim()).filter(Boolean))].sort();
77
+ if (names.length === 0) return undefined;
78
+
79
+ return ['Available subagents:', ...names.map((name) => `- ${name}`)].join('\n');
80
+ }
81
+
82
+ export function appendAvailableSubagentsBlock(systemPrompt: string, agentNames: string[]): string {
83
+ const block = formatAvailableSubagentsBlock(agentNames);
84
+ if (!block || systemPrompt.includes(block)) return systemPrompt;
85
+
86
+ return `${systemPrompt.trimEnd()}\n\n${block}`;
87
+ }
@@ -8,7 +8,12 @@ import {
8
8
  } from '@earendil-works/pi-coding-agent';
9
9
  import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui';
10
10
  import type { AgentConfig } from './agent-loader.ts';
11
- import { type AgentProgress, type AgentResult, runSubagent } from './subagent-executor.ts';
11
+ import {
12
+ availableSubagentsForAgent,
13
+ type AgentProgress,
14
+ type AgentResult,
15
+ runSubagent,
16
+ } from './subagent-executor.ts';
12
17
  import {
13
18
  contextUsageSeverity,
14
19
  formatSubagentCall,
@@ -217,10 +222,8 @@ export function registerSubagentTool(
217
222
  : options.agents;
218
223
  const runner = options.run ?? runSubagent;
219
224
 
220
- const agentNames = agents
221
- .map((a) => a.name)
222
- .sort()
223
- .join(', ');
225
+ const availableSubagents = agents.map((agent) => agent.name);
226
+ const agentNames = [...availableSubagents].sort().join(', ');
224
227
  const promptGuidelines =
225
228
  agentNames.length > 0 ? [`Available subagents: ${agentNames}`] : undefined;
226
229
 
@@ -252,6 +255,7 @@ export function registerSubagentTool(
252
255
  cwd: params.cwd ?? ctx.cwd,
253
256
  signal,
254
257
  depth: Number(env.PI_SUBAGENT_DEPTH ?? '0') + 1,
258
+ availableAgents: availableSubagentsForAgent(agent, availableSubagents),
255
259
  onProgress: (progress) => onUpdate?.(toProgressResult(progress)),
256
260
  });
257
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnnywu/pi-subagents",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Sub-agents extension for pi coding agent.",
5
5
  "homepage": "https://github.com/jwu/pi-subagents#readme",
6
6
  "repository": {