@johnnywu/pi-subagents 1.2.0 → 1.4.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,26 @@
1
+ # [1.4.0](https://github.com/jwu/pi-subagents/compare/v1.3.0...v1.4.0) (2026-06-02)
2
+
3
+
4
+ ### Features
5
+
6
+ * support wildcard skills ([962eaa4](https://github.com/jwu/pi-subagents/commit/962eaa486cd73342629cfecf4e7bcc8a1ceb4710))
7
+
8
+ # [1.3.0](https://github.com/jwu/pi-subagents/compare/v1.2.0...v1.3.0) (2026-06-02)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * filter available subagents by allowed agents ([adc6025](https://github.com/jwu/pi-subagents/commit/adc60252d96ef532719a0da1c128a8e433c74bb7))
14
+ * preserve append-mode subagent context ([ada0a26](https://github.com/jwu/pi-subagents/commit/ada0a264565d686917b73126301884345f824bce))
15
+
16
+
17
+ ### Features
18
+
19
+ * add debug subagent prompt command ([1c49d43](https://github.com/jwu/pi-subagents/commit/1c49d43af9569856407e2a97d512d9d3b617e6d9))
20
+ * default subagent prompts to append ([5693c17](https://github.com/jwu/pi-subagents/commit/5693c17b2aea4244b106bff82fe7ebd5c954322c))
21
+ * export subagent debug prompts from frontmatter ([afe8b23](https://github.com/jwu/pi-subagents/commit/afe8b23700b9a86ef1db97463225cd828f884026))
22
+ * resolve package skills for subagents ([48651e1](https://github.com/jwu/pi-subagents/commit/48651e15010c501cacec9a2e71771a13c0e7c9ab))
23
+
1
24
  # [1.2.0](https://github.com/jwu/pi-subagents/compare/v1.1.0...v1.2.0) (2026-06-02)
2
25
 
3
26
 
package/README.md CHANGED
@@ -50,11 +50,11 @@ Run the code-reviewer agent on the last three commits
50
50
 
51
51
  ### Available subagents in the prompt
52
52
 
53
- The extension exposes discovered sub-agents to the model in two places:
53
+ The extension exposes discovered sub-agents to the model when the active tool set includes `subagent`:
54
54
 
55
55
  - The `subagent` tool keeps a one-line prompt guideline:
56
56
  `Available subagents: code-reviewer, refactor, test-writer`
57
- - The system prompt also gets an independent block:
57
+ - At agent-start time, the system prompt gets an independent block:
58
58
 
59
59
  ```text
60
60
  Available subagents:
@@ -63,7 +63,25 @@ Available subagents:
63
63
  - test-writer
64
64
  ```
65
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.
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.
67
85
 
68
86
  ## Agent configuration
69
87
 
@@ -76,12 +94,16 @@ Agents are Markdown files with YAML frontmatter.
76
94
  | `tools` | no | _none_ | Comma-separated tool whitelist (`read, write, bash, grep`, etc.) |
77
95
  | `model` | no | parent's model | Provider/model-id (`anthropic/claude-sonnet-4-6`) |
78
96
  | `thinking` | no | `off` | Reasoning level: `off`, `low`, `medium`, `high` |
79
- | `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) |
98
+ | `skills` | no | _none_ | Comma-separated skill names or simple wildcard patterns (`*`, `obsidian-*`) to load (resolved from project `.agents/skills/`, `.pi/skills/`, global `~/.pi/agent/skills/`, or npm packages) |
80
99
  | `allowedAgents` | no | _all_ | Comma-separated list of sub-agents this agent may spawn |
81
100
  | `maxDepth` | no | `10` | Maximum recursion depth (`0` = no sub-agents, `1` = one level, etc.) |
101
+ | `debug` | no | `false` | When `true`, export the effective runtime system prompt to `debug-system-prompt.md` |
82
102
 
83
103
  The Markdown body after the frontmatter is the agent's system prompt.
84
104
 
105
+ `skills` entries match the skill frontmatter `name`. Use `skills: *` to load all available skills, or prefix-style patterns such as `skills: obsidian-*` to load matching skills. Only `*` is supported as a wildcard; glob features like `?` or `{a,b}` are not supported.
106
+
85
107
  ### Example with all fields
86
108
 
87
109
  ```markdown
@@ -91,9 +113,11 @@ description: High-level planner that delegates to specialists
91
113
  tools: subagent, read, grep, find
92
114
  model: anthropic/claude-sonnet-4-6
93
115
  thinking: high
94
- systemPrompt: replace
116
+ systemPrompt: append
117
+ skills: tdd, obsidian-*
95
118
  allowedAgents: code-reviewer, refactor, test-writer
96
119
  maxDepth: 2
120
+ debug: false
97
121
  ---
98
122
 
99
123
  You are an orchestrator. Break complex tasks into sub-tasks and delegate
@@ -121,7 +145,7 @@ Sub-agents can spawn their own sub-agents (if the `subagent` tool is in their wh
121
145
 
122
146
  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
147
 
124
- These are passed via environment variables (`PI_SUBAGENT_DEPTH`, `PI_SUBAGENT_MAX_DEPTH`, `PI_SUBAGENT_ALLOWED`).
148
+ 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.
125
149
 
126
150
  ## Session storage
127
151
 
@@ -15,6 +15,7 @@ export interface AgentConfig {
15
15
  systemPromptMode: SystemPromptMode;
16
16
  allowedAgents?: string[];
17
17
  maxDepth: number;
18
+ debug: boolean;
18
19
  skills?: string[];
19
20
  prompt: string;
20
21
  source: AgentSource;
@@ -107,7 +108,7 @@ function parseAgentFile(content: string, filePath: string, source: AgentSource):
107
108
  throw new Error(`invalid thinking: ${data.thinking}`);
108
109
  }
109
110
 
110
- const systemPromptMode = (data.systemPrompt ?? 'replace') as SystemPromptMode;
111
+ const systemPromptMode = (data.systemPrompt ?? 'append') as SystemPromptMode;
111
112
  if (!['replace', 'append'].includes(systemPromptMode)) {
112
113
  throw new Error(`invalid systemPrompt: ${data.systemPrompt}`);
113
114
  }
@@ -117,6 +118,14 @@ function parseAgentFile(content: string, filePath: string, source: AgentSource):
117
118
  throw new Error(`invalid maxDepth: ${data.maxDepth}`);
118
119
  }
119
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
+
120
129
  const allowedAgents = splitCsv(data.allowedAgents);
121
130
  const skills = splitCsv(data.skills);
122
131
 
@@ -130,6 +139,7 @@ function parseAgentFile(content: string, filePath: string, source: AgentSource):
130
139
  systemPromptMode,
131
140
  allowedAgents: allowedAgents.length > 0 ? allowedAgents : undefined,
132
141
  maxDepth,
142
+ debug,
133
143
  prompt: body,
134
144
  source,
135
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,7 +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';
3
- import { allowedAgentNames, isPastMaxDepth } from './env-utils.ts';
4
- import { appendAvailableSubagentsBlock } from './subagent-prompt.ts';
5
+ import { allowedAgentNames, isPastMaxDepth, isSubagentReplaceSystemPrompt } from './env-utils.ts';
6
+ import {
7
+ appendAvailableSubagentsBlock,
8
+ appendAvailableToolsAndGuidelinesBlock,
9
+ } from './subagent-prompt.ts';
5
10
  import { registerSubagentTool } from './subagent-tool.ts';
6
11
 
7
12
  export default async function (pi: ExtensionAPI) {
@@ -17,10 +22,32 @@ export default async function (pi: ExtensionAPI) {
17
22
  : result.agents;
18
23
  const agentNames = agents.map((agent) => agent.name);
19
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
+
20
36
  if (!isPastMaxDepth(process.env) && agentNames.length > 0) {
21
- pi.on('before_agent_start', (event) => ({
22
- systemPrompt: appendAvailableSubagentsBlock(event.systemPrompt, agentNames),
23
- }));
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
+ });
24
51
  }
25
52
 
26
53
  registerSubagentTool(pi, { agents: result.agents });
@@ -1,3 +1,9 @@
1
+ import {
2
+ DefaultPackageManager,
3
+ SettingsManager,
4
+ getAgentDir,
5
+ type ResolvedResource,
6
+ } from '@earendil-works/pi-coding-agent';
1
7
  import * as fs from 'node:fs';
2
8
  import * as os from 'node:os';
3
9
  import * as path from 'node:path';
@@ -11,12 +17,22 @@ export interface ResolvedSkill {
11
17
  export interface SkillResolverFs {
12
18
  exists(filePath: string): boolean;
13
19
  readFile(filePath: string): string;
20
+ listFiles?(dir: string): string[];
14
21
  }
15
22
 
16
23
  export interface ResolveSkillsOptions {
17
24
  cwd: string;
25
+ agentDir?: string;
18
26
  globalDir?: string;
19
27
  fs?: SkillResolverFs;
28
+ packageSkillFiles?: string[];
29
+ }
30
+
31
+ export interface ResolveSkillsResult {
32
+ resolved: ResolvedSkill[];
33
+ missing: string[];
34
+ skippedPackages: string[];
35
+ warnings: string[];
20
36
  }
21
37
 
22
38
  const defaultSkillFs: SkillResolverFs = {
@@ -26,6 +42,16 @@ const defaultSkillFs: SkillResolverFs = {
26
42
  readFile(filePath) {
27
43
  return fs.readFileSync(filePath, 'utf-8');
28
44
  },
45
+ listFiles(dir) {
46
+ try {
47
+ return fs
48
+ .readdirSync(dir, { withFileTypes: true })
49
+ .map((entry) => path.join(dir, entry.name))
50
+ .sort();
51
+ } catch {
52
+ return [];
53
+ }
54
+ },
29
55
  };
30
56
 
31
57
  function defaultGlobalSkillsDir(): string {
@@ -75,72 +101,200 @@ function parseSkillFrontmatter(content: string): { name: string; description: st
75
101
  }
76
102
  delete data._currentBlockKey;
77
103
 
78
- if (!data.description || !data.description.trim()) return undefined;
79
- return { name: data.name || '', description: data.description.trim() };
104
+ return { name: data.name || '', description: (data.description ?? '').trim() };
80
105
  }
81
106
 
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;
107
+ async function resolvePackageSkillFiles(options: ResolveSkillsOptions): Promise<{
108
+ files: string[];
109
+ skippedPackages: string[];
110
+ }> {
111
+ if (options.packageSkillFiles) {
112
+ return { files: options.packageSkillFiles, skippedPackages: [] };
113
+ }
114
+ if (options.fs) {
115
+ return { files: [], skippedPackages: [] };
116
+ }
117
+
118
+ const skippedPackages: string[] = [];
119
+ const agentDir = options.agentDir ?? getAgentDir();
120
+ const settingsManager = SettingsManager.create(options.cwd, agentDir);
121
+ const packageManager = new DefaultPackageManager({
122
+ cwd: options.cwd,
123
+ agentDir,
124
+ settingsManager,
125
+ });
126
+ const resolvedPaths = await packageManager.resolve(async (source) => {
127
+ skippedPackages.push(source);
128
+ return 'skip';
129
+ });
130
+
131
+ return {
132
+ files: resolvedPaths.skills
133
+ .filter((resource: ResolvedResource) => resource.enabled)
134
+ .map((resource: ResolvedResource) => resource.path),
135
+ skippedPackages,
136
+ };
137
+ }
138
+
139
+ interface ReadSkillResult {
140
+ skill?: ResolvedSkill;
141
+ warning?: string;
142
+ }
91
143
 
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;
144
+ function readSkill(filePath: string, fileSystem: SkillResolverFs): ReadSkillResult | undefined {
145
+ const content = fileSystem.readFile(filePath);
146
+ const frontmatter = parseSkillFrontmatter(content);
147
+ if (!frontmatter) return undefined;
95
148
 
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;
149
+ const fileSkillName = frontmatter.name.trim();
150
+ if (!fileSkillName) {
151
+ return { warning: `skill missing required field: name: ${filePath}` };
152
+ }
153
+ if (!frontmatter.description) return undefined;
99
154
 
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;
155
+ return {
156
+ skill: {
157
+ name: fileSkillName,
158
+ description: frontmatter.description,
159
+ location: filePath,
160
+ },
161
+ };
162
+ }
103
163
 
104
- return undefined;
164
+ interface CollectedSkills {
165
+ byName: Map<string, ResolvedSkill>;
166
+ warnings: string[];
167
+ skippedPackages: string[];
105
168
  }
106
169
 
107
- export function resolveSkills(
108
- skillNames: string[],
109
- options: ResolveSkillsOptions,
110
- ): { resolved: ResolvedSkill[]; missing: string[] } {
170
+ const collectedSkillsCache = new Map<string, Promise<CollectedSkills>>();
171
+
172
+ function listSkillFilesInDir(dir: string, fileSystem: SkillResolverFs): string[] {
173
+ if (!fileSystem.listFiles) return [];
174
+
175
+ return fileSystem
176
+ .listFiles(dir)
177
+ .map((entry) => path.join(entry, 'SKILL.md'))
178
+ .filter((filePath) => fileSystem.exists(filePath));
179
+ }
180
+
181
+ function addSkillFile(
182
+ filePath: string,
183
+ fileSystem: SkillResolverFs,
184
+ byName: Map<string, ResolvedSkill>,
185
+ warnings: string[],
186
+ ): void {
187
+ try {
188
+ const result = readSkill(filePath, fileSystem);
189
+ if (!result) return;
190
+ if (result.warning) warnings.push(result.warning);
191
+ if (result.skill && !byName.has(result.skill.name)) byName.set(result.skill.name, result.skill);
192
+ } catch (error) {
193
+ warnings.push(
194
+ `skill could not be read: ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
195
+ );
196
+ }
197
+ }
198
+
199
+ async function collectSkills(options: ResolveSkillsOptions): Promise<CollectedSkills> {
111
200
  const cwd = options.cwd;
112
201
  const globalDir = options.globalDir ?? defaultGlobalSkillsDir();
113
202
  const fileSystem = options.fs ?? defaultSkillFs;
203
+ const byName = new Map<string, ResolvedSkill>();
204
+ const warnings: string[] = [];
205
+
206
+ for (const dir of [
207
+ path.join(cwd, '.agents', 'skills'),
208
+ path.join(cwd, '.pi', 'skills'),
209
+ globalDir,
210
+ path.join(os.homedir(), '.agents', 'skills'),
211
+ ]) {
212
+ for (const filePath of listSkillFilesInDir(dir, fileSystem)) {
213
+ addSkillFile(filePath, fileSystem, byName, warnings);
214
+ }
215
+ }
216
+
217
+ const packageSkills = await resolvePackageSkillFiles(options);
218
+ for (const filePath of packageSkills.files) {
219
+ addSkillFile(filePath, fileSystem, byName, warnings);
220
+ }
221
+
222
+ return { byName, warnings, skippedPackages: packageSkills.skippedPackages };
223
+ }
224
+
225
+ function skillsCacheKey(options: ResolveSkillsOptions): string | undefined {
226
+ if (options.fs) return undefined;
227
+ return JSON.stringify({
228
+ cwd: options.cwd,
229
+ agentDir: options.agentDir,
230
+ globalDir: options.globalDir ?? defaultGlobalSkillsDir(),
231
+ packageSkillFiles: options.packageSkillFiles,
232
+ });
233
+ }
234
+
235
+ async function getCollectedSkills(options: ResolveSkillsOptions): Promise<CollectedSkills> {
236
+ const cacheKey = skillsCacheKey(options);
237
+ if (!cacheKey) return collectSkills(options);
238
+
239
+ let cached = collectedSkillsCache.get(cacheKey);
240
+ if (!cached) {
241
+ cached = collectSkills(options);
242
+ collectedSkillsCache.set(cacheKey, cached);
243
+ }
244
+ return cached;
245
+ }
246
+
247
+ function wildcardToRegex(pattern: string): RegExp {
248
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
249
+ return new RegExp(`^${escaped}$`);
250
+ }
251
+
252
+ function hasWildcard(pattern: string): boolean {
253
+ return pattern.includes('*');
254
+ }
255
+
256
+ export async function resolveSkills(
257
+ skillNames: string[],
258
+ options: ResolveSkillsOptions,
259
+ ): Promise<ResolveSkillsResult> {
260
+ const requestedNames = skillNames.map((name) => name.trim()).filter(Boolean);
261
+ if (requestedNames.length === 0) {
262
+ return { resolved: [], missing: [], skippedPackages: [], warnings: [] };
263
+ }
264
+
265
+ const collected = await getCollectedSkills(options);
114
266
  const resolved: ResolvedSkill[] = [];
115
267
  const missing: string[] = [];
268
+ const seen = new Set<string>();
116
269
 
117
- for (const name of skillNames) {
118
- const trimmed = name.trim();
119
- if (!trimmed) continue;
270
+ function addSkill(skill: ResolvedSkill): void {
271
+ if (seen.has(skill.name)) return;
272
+ seen.add(skill.name);
273
+ resolved.push(skill);
274
+ }
120
275
 
121
- const filePath = findSkillFile(trimmed, cwd, globalDir, fileSystem);
122
- if (!filePath) {
123
- missing.push(trimmed);
276
+ for (const requestedName of requestedNames) {
277
+ if (hasWildcard(requestedName)) {
278
+ const regex = wildcardToRegex(requestedName);
279
+ let matched = false;
280
+ for (const skill of collected.byName.values()) {
281
+ if (!regex.test(skill.name)) continue;
282
+ matched = true;
283
+ addSkill(skill);
284
+ }
285
+ if (!matched) missing.push(requestedName);
124
286
  continue;
125
287
  }
126
288
 
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
- }
289
+ const skill = collected.byName.get(requestedName);
290
+ if (skill) addSkill(skill);
291
+ else missing.push(requestedName);
143
292
  }
144
293
 
145
- return { resolved, missing };
294
+ return {
295
+ resolved,
296
+ missing,
297
+ skippedPackages: collected.skippedPackages,
298
+ warnings: collected.warnings,
299
+ };
146
300
  }
@@ -5,7 +5,6 @@ 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
8
  import { resolveSkills } from './skill-resolver.ts';
10
9
 
11
10
  export interface AgentUsage {
@@ -89,10 +88,39 @@ export interface RunSubagentOptions {
89
88
  now?: () => number;
90
89
  }
91
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
+ skillWarnings: string[];
102
+ }
103
+
92
104
  const TASK_FILE_THRESHOLD = 8000;
93
105
  const OUTPUT_MAX_BYTES = 50 * 1024;
94
106
  const OUTPUT_MAX_LINES = 2000;
95
107
 
108
+ export function availableSubagentsForAgent(
109
+ agent: AgentConfig,
110
+ candidateNames?: string[],
111
+ ): string[] {
112
+ const names = candidateNames ?? agent.allowedAgents ?? [];
113
+ const allowed = agent.allowedAgents ? new Set(agent.allowedAgents) : undefined;
114
+ const seen = new Set<string>();
115
+
116
+ return names.filter((name) => {
117
+ if (seen.has(name)) return false;
118
+ if (allowed && !allowed.has(name)) return false;
119
+ seen.add(name);
120
+ return true;
121
+ });
122
+ }
123
+
96
124
  const defaultFs: ExecutorFs = {
97
125
  makeTempDir(prefix) {
98
126
  return fs.mkdtemp(prefix);
@@ -333,6 +361,33 @@ function safeFilePart(value: string): string {
333
361
  return value.replace(/[^a-zA-Z0-9_.-]+/g, '_');
334
362
  }
335
363
 
364
+ export async function buildSubagentSystemPrompt(
365
+ options: BuildSubagentSystemPromptOptions,
366
+ ): Promise<BuildSubagentSystemPromptResult> {
367
+ let prompt = options.agent.prompt;
368
+
369
+ const missingSkills: string[] = [];
370
+ const skippedSkillPackages: string[] = [];
371
+ const skillWarnings: string[] = [];
372
+ const skillNames = options.agent.skills;
373
+ if (skillNames && skillNames.length > 0) {
374
+ const resolvedSkills = await resolveSkills(skillNames, {
375
+ cwd: options.cwd,
376
+ agentDir: options.agentDir,
377
+ });
378
+ missingSkills.push(...resolvedSkills.missing);
379
+ skippedSkillPackages.push(...resolvedSkills.skippedPackages);
380
+ skillWarnings.push(...resolvedSkills.warnings);
381
+
382
+ const skillInjection = formatSkillsForPrompt(resolvedSkills.resolved);
383
+ if (skillInjection) {
384
+ prompt = `${prompt}${skillInjection}`;
385
+ }
386
+ }
387
+
388
+ return { prompt, missingSkills, skippedSkillPackages, skillWarnings };
389
+ }
390
+
336
391
  function progressFromPartialResult(partialResult: unknown): AgentProgress | undefined {
337
392
  if (!partialResult || typeof partialResult !== 'object') return undefined;
338
393
  const details = (partialResult as { details?: unknown }).details;
@@ -377,28 +432,21 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
377
432
  try {
378
433
  const promptFilePath = path.join(tempDir, 'system-prompt.md');
379
434
 
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
- }
435
+ const promptResult = await buildSubagentSystemPrompt({
436
+ agent: options.agent,
437
+ cwd: options.cwd,
438
+ agentDir: options.agentDir,
439
+ });
440
+ for (const source of promptResult.skippedSkillPackages) {
441
+ console.warn(`[pi-subagents] package not installed, skipping skills: ${source}`);
388
442
  }
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
- }
443
+ for (const warning of promptResult.skillWarnings) {
444
+ console.warn(`[pi-subagents] ${warning}`);
445
+ }
446
+ for (const name of promptResult.missingSkills) {
447
+ console.warn(`[pi-subagents] skill not found: ${name}`);
400
448
  }
401
- await fileSystem.writeFile(promptFilePath, promptContent);
449
+ await fileSystem.writeFile(promptFilePath, promptResult.prompt);
402
450
 
403
451
  let taskFilePath: string | undefined;
404
452
  if (options.task.length > TASK_FILE_THRESHOLD) {
@@ -411,16 +459,9 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
411
459
  AuthStorage.create(options.agentDir ? path.join(options.agentDir, 'auth.json') : undefined),
412
460
  options.agentDir ? path.join(options.agentDir, 'models.json') : undefined,
413
461
  );
414
- const args = [
415
- pi.entryPoint,
416
- '--mode',
417
- 'json',
418
- '-p',
419
- '--no-skills',
420
- '--no-prompt-templates',
421
- '--no-context-files',
422
- ];
462
+ const args = [pi.entryPoint, '--mode', 'json', '-p', '--no-skills', '--no-prompt-templates'];
423
463
 
464
+ if (options.agent.systemPromptMode === 'replace') args.push('--no-context-files');
424
465
  if (options.agent.model) args.push('--model', options.agent.model);
425
466
  args.push('--thinking', options.agent.thinking);
426
467
  if (options.agent.tools.length > 0) args.push('--tools', options.agent.tools.join(','));
@@ -435,10 +476,14 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
435
476
  ...process.env,
436
477
  PI_SUBAGENT_DEPTH: String(options.depth ?? 1),
437
478
  PI_SUBAGENT_MAX_DEPTH: String(options.agent.maxDepth),
479
+ PI_SUBAGENT_NAME: options.agent.name,
480
+ PI_SUBAGENT_SYSTEM_PROMPT_MODE: options.agent.systemPromptMode,
438
481
  };
439
- if (options.agent.tools.includes('subagent') && options.agent.allowedAgents?.length) {
440
- env.PI_SUBAGENT_ALLOWED = options.agent.allowedAgents.join(',');
482
+ const visibleAgents = availableSubagentsForAgent(options.agent, options.availableAgents);
483
+ if (visibleAgents.length > 0) {
484
+ env.PI_SUBAGENT_ALLOWED = visibleAgents.join(',');
441
485
  }
486
+ env.PI_SUBAGENT_DEBUG = options.agent.debug ? 'true' : 'false';
442
487
 
443
488
  const processLine = (line: string) => {
444
489
  if (!line.trim()) return;
@@ -1,3 +1,77 @@
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
+
1
75
  export function formatAvailableSubagentsBlock(agentNames: string[]): string | undefined {
2
76
  const names = [...new Set(agentNames.map((name) => name.trim()).filter(Boolean))].sort();
3
77
  if (names.length === 0) return undefined;
@@ -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,
@@ -250,7 +255,7 @@ export function registerSubagentTool(
250
255
  cwd: params.cwd ?? ctx.cwd,
251
256
  signal,
252
257
  depth: Number(env.PI_SUBAGENT_DEPTH ?? '0') + 1,
253
- availableAgents: availableSubagents,
258
+ availableAgents: availableSubagentsForAgent(agent, availableSubagents),
254
259
  onProgress: (progress) => onUpdate?.(toProgressResult(progress)),
255
260
  });
256
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnnywu/pi-subagents",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Sub-agents extension for pi coding agent.",
5
5
  "homepage": "https://github.com/jwu/pi-subagents#readme",
6
6
  "repository": {