@narumitw/pi-subagents 0.40.0 → 0.41.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/README.md CHANGED
@@ -58,6 +58,12 @@ The available tools are:
58
58
  - `subagent` — delegate blocking single, parallel, fan-in, or chained batch work. The main agent cannot process queued steering until the call returns.
59
59
  - `subagent_spawn` and related lifecycle tools — when enabled, start reusable detached work, return immediately, and receive bounded completion messages automatically.
60
60
 
61
+ After each session starts, both delegation tools include a bounded parent-facing catalog of the agents
62
+ available in that session. Entries show the source (`built-in`, `user`, or `project`) and the
63
+ `agentScope` needed to invoke them; the `agent` parameters remain unconstrained strings for cwd and
64
+ scope flexibility. The catalog is rebuilt on `/reload` or the next session start, and omitted entries
65
+ are reported explicitly when the catalog exceeds its metadata bounds.
66
+
61
67
  Choose the API by lifecycle:
62
68
 
63
69
  | Need | Use |
@@ -89,6 +95,17 @@ When registered, the blocking `subagent` tool advertises only blocking guidance.
89
95
  are registered, `subagent_spawn` adds detached guidance for the active completion-delivery policy.
90
96
  Changing the policy through `/subagents settings` refreshes that guidance immediately.
91
97
 
98
+ The same descriptions also advertise the current agent catalog automatically; no preliminary list call is
99
+ needed. Built-ins and user agents appear under the default `agentScope: "user"`. Trusted project
100
+ agents appear separately and explicitly require `agentScope: "project"` or `"both"`; project-authored
101
+ names and descriptions are not read into metadata for untrusted projects. If a project definition
102
+ shares a name with a user or built-in definition, the user version is the default and the project
103
+ version is used only for `"project"`/`"both"`. A user override of a built-in also shows the
104
+ built-in fallback available with `agentScope: "project"`; `"both"` keeps the user definition. The
105
+ catalog is bounded and reports its omission count; metadata discovery also caps files and bytes read
106
+ per scope. Refreshed metadata replaces the previous session's catalog rather than accumulating stale
107
+ entries.
108
+
92
109
  Count-selection guidance:
93
110
 
94
111
  - Use **no subagent** for simple answers, quick targeted edits, latency-sensitive one-step work, or
@@ -429,8 +446,10 @@ test coverage, and migration risks. Report PASS/FAIL/PARTIAL with evidence.
429
446
  ```
430
447
 
431
448
  `agentScope` is a top-level tool argument supplied per invocation. It is not a setting in
432
- `~/.pi/agent/pi-subagents.json` and does not belong in agent frontmatter. The scope selects which
433
- custom agent directories are loaded; built-in agents remain available in every scope:
449
+ `~/.pi/agent/pi-subagents.json` and does not belong in agent frontmatter. The parent-facing tool
450
+ metadata discovers these definitions after session start and labels their source and required scope.
451
+ Edit agent files and run `/reload` (or start a new session) to refresh the catalog; there is no live
452
+ filesystem watcher. The scope selects which custom agent directories are loaded; built-in agents remain available in every scope:
434
453
 
435
454
  | `agentScope` | Custom agents loaded |
436
455
  | --- | --- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@narumitw/pi-tui-kit": "<1",
32
+ "@narumitw/pi-tui-kit": "^0.40.0",
33
33
  "proper-lockfile": "^4.1.2",
34
34
  "typebox": "^1.3.8"
35
35
  },
package/src/agents.ts CHANGED
@@ -139,40 +139,109 @@ function workerSystemPrompt(): string {
139
139
  export interface AgentDiscoveryResult {
140
140
  agents: AgentConfig[];
141
141
  projectAgentsDir: string | null;
142
+ omittedAgentDefinitions?: number;
143
+ metadataDiscoveryIncomplete?: boolean;
142
144
  }
143
145
 
144
- function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
145
- const agents: AgentConfig[] = [];
146
+ export interface AgentDiscoveryOptions {
147
+ maxFiles?: number;
148
+ maxFileBytes?: number;
149
+ maxTotalBytes?: number;
150
+ }
151
+
152
+ interface LoadedAgents {
153
+ agents: AgentConfig[];
154
+ omittedAgentDefinitions: number;
155
+ metadataDiscoveryIncomplete: boolean;
156
+ }
146
157
 
147
- if (!fs.existsSync(dir)) {
148
- return agents;
158
+ function readFileBoundedSync(
159
+ filePath: string,
160
+ maxBytes: number | undefined,
161
+ ): { content?: string; bytes: number; limited: boolean } {
162
+ if (maxBytes === undefined) {
163
+ try {
164
+ const content = fs.readFileSync(filePath, "utf-8");
165
+ return { content, bytes: Buffer.byteLength(content), limited: false };
166
+ } catch {
167
+ return { bytes: 0, limited: false };
168
+ }
149
169
  }
150
170
 
171
+ const readLimit = Math.max(0, maxBytes);
172
+ let fd: number | undefined;
173
+ try {
174
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
175
+ if (!fs.fstatSync(fd).isFile()) return { bytes: 0, limited: false };
176
+ const buffer = Buffer.allocUnsafe(readLimit + 1);
177
+ let offset = 0;
178
+ while (offset < buffer.length) {
179
+ const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, null);
180
+ if (bytesRead === 0) break;
181
+ offset += bytesRead;
182
+ }
183
+ if (offset > readLimit) return { bytes: offset, limited: true };
184
+ return { content: buffer.subarray(0, offset).toString("utf-8"), bytes: offset, limited: false };
185
+ } catch {
186
+ return { bytes: 0, limited: false };
187
+ } finally {
188
+ if (fd !== undefined) fs.closeSync(fd);
189
+ }
190
+ }
191
+
192
+ function loadAgentsFromDir(
193
+ dir: string,
194
+ source: "user" | "project",
195
+ options: AgentDiscoveryOptions = {},
196
+ ): LoadedAgents {
197
+ const agents: AgentConfig[] = [];
198
+ let omittedAgentDefinitions = 0;
199
+
151
200
  let entries: fs.Dirent[];
152
201
  try {
153
202
  entries = fs.readdirSync(dir, { withFileTypes: true });
154
- } catch {
155
- return agents;
203
+ } catch (error) {
204
+ return {
205
+ agents,
206
+ omittedAgentDefinitions,
207
+ metadataDiscoveryIncomplete: (error as NodeJS.ErrnoException).code !== "ENOENT",
208
+ };
156
209
  }
157
210
 
158
- for (const entry of entries) {
159
- if (!entry.name.endsWith(".md")) continue;
160
- if (!entry.isFile() && !entry.isSymbolicLink()) continue;
211
+ const agentEntries = entries
212
+ .filter((entry) => entry.name.endsWith(".md"))
213
+ .filter((entry) => entry.isFile() || entry.isSymbolicLink());
214
+ let totalBytes = 0;
161
215
 
216
+ for (const [index, entry] of agentEntries.entries()) {
217
+ if (options.maxFiles !== undefined && index >= options.maxFiles) {
218
+ omittedAgentDefinitions += agentEntries.length - index;
219
+ break;
220
+ }
162
221
  const filePath = path.join(dir, entry.name);
163
- let content: string;
164
- try {
165
- content = fs.readFileSync(filePath, "utf-8");
166
- } catch {
222
+ const remainingBytes =
223
+ options.maxTotalBytes === undefined ? undefined : options.maxTotalBytes - totalBytes;
224
+ if (remainingBytes !== undefined && remainingBytes <= 0) {
225
+ omittedAgentDefinitions++;
167
226
  continue;
168
227
  }
169
-
170
- const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
171
-
172
- if (!frontmatter.name || !frontmatter.description) {
228
+ const maxBytes =
229
+ options.maxFileBytes === undefined
230
+ ? remainingBytes
231
+ : remainingBytes === undefined
232
+ ? options.maxFileBytes
233
+ : Math.min(options.maxFileBytes, remainingBytes);
234
+ const loaded = readFileBoundedSync(filePath, maxBytes);
235
+ totalBytes += Math.min(loaded.bytes, maxBytes ?? loaded.bytes);
236
+ if (loaded.limited || loaded.content === undefined) {
237
+ if (loaded.limited) omittedAgentDefinitions++;
173
238
  continue;
174
239
  }
175
240
 
241
+ const { frontmatter, body } = parseFrontmatter<Record<string, string>>(loaded.content);
242
+
243
+ if (!frontmatter.name || !frontmatter.description) continue;
244
+
176
245
  const tools = frontmatter.tools
177
246
  ?.split(",")
178
247
  .map((t: string) => t.trim())
@@ -192,7 +261,7 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
192
261
  });
193
262
  }
194
263
 
195
- return agents;
264
+ return { agents, omittedAgentDefinitions, metadataDiscoveryIncomplete: false };
196
265
  }
197
266
 
198
267
  function isDirectory(p: string): boolean {
@@ -223,13 +292,21 @@ export function discoverAgents(
223
292
  cwd: string,
224
293
  scope: AgentScope,
225
294
  config?: SubagentSettings,
295
+ options: AgentDiscoveryOptions = {},
226
296
  ): AgentDiscoveryResult {
227
297
  const userDir = path.join(getAgentDir(), "agents");
228
298
  const projectAgentsDir = findNearestProjectAgentsDir(cwd);
229
299
 
230
- const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
231
- const projectAgents =
232
- scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
300
+ const userLoaded =
301
+ scope === "project"
302
+ ? { agents: [], omittedAgentDefinitions: 0, metadataDiscoveryIncomplete: false }
303
+ : loadAgentsFromDir(userDir, "user", options);
304
+ const projectLoaded =
305
+ scope === "user" || !projectAgentsDir
306
+ ? { agents: [], omittedAgentDefinitions: 0, metadataDiscoveryIncomplete: false }
307
+ : loadAgentsFromDir(projectAgentsDir, "project", options);
308
+ const userAgents = userLoaded.agents;
309
+ const projectAgents = projectLoaded.agents;
233
310
 
234
311
  const agentMap = new Map<string, AgentConfig>();
235
312
 
@@ -268,7 +345,16 @@ export function discoverAgents(
268
345
  agentMap.set(name, nextAgent);
269
346
  }
270
347
 
271
- return { agents: Array.from(agentMap.values()), projectAgentsDir };
348
+ const omittedAgentDefinitions =
349
+ userLoaded.omittedAgentDefinitions + projectLoaded.omittedAgentDefinitions;
350
+ const metadataDiscoveryIncomplete =
351
+ userLoaded.metadataDiscoveryIncomplete || projectLoaded.metadataDiscoveryIncomplete;
352
+ return {
353
+ agents: Array.from(agentMap.values()),
354
+ projectAgentsDir,
355
+ ...(omittedAgentDefinitions > 0 ? { omittedAgentDefinitions } : {}),
356
+ ...(metadataDiscoveryIncomplete ? { metadataDiscoveryIncomplete } : {}),
357
+ };
272
358
  }
273
359
 
274
360
  export function formatAgentList(
@@ -283,3 +369,203 @@ export function formatAgentList(
283
369
  remaining,
284
370
  };
285
371
  }
372
+
373
+ export interface AgentCatalog {
374
+ /** The effective catalog for the default invocation scope. */
375
+ user: AgentDiscoveryResult;
376
+ /** The project-scope catalog; custom project definitions are loaded only after project trust. */
377
+ project?: AgentDiscoveryResult;
378
+ }
379
+
380
+ export interface AgentCatalogFormatOptions {
381
+ maxItems?: number;
382
+ maxDescriptionLength?: number;
383
+ maxCharacters?: number;
384
+ }
385
+
386
+ export interface AgentCatalogFormatResult {
387
+ text: string;
388
+ omitted: number;
389
+ }
390
+
391
+ export const DEFAULT_AGENT_CATALOG_MAX_ITEMS = 32;
392
+ export const DEFAULT_AGENT_CATALOG_MAX_DESCRIPTION_LENGTH = 240;
393
+ export const DEFAULT_AGENT_CATALOG_MAX_CHARACTERS = 6_000;
394
+ export const DEFAULT_AGENT_CATALOG_MAX_FILES_PER_SCOPE = 128;
395
+ export const DEFAULT_AGENT_CATALOG_MAX_FILE_BYTES = 64 * 1024;
396
+ export const DEFAULT_AGENT_CATALOG_MAX_TOTAL_BYTES_PER_SCOPE = 2 * 1024 * 1024;
397
+
398
+ const BUILT_IN_AGENT_ORDER = new Map(BUILT_IN_AGENTS.map((agent, index) => [agent.name, index]));
399
+
400
+ function compareCatalogAgents(left: AgentConfig, right: AgentConfig): number {
401
+ const leftBuiltInOrder = BUILT_IN_AGENT_ORDER.get(left.name);
402
+ const rightBuiltInOrder = BUILT_IN_AGENT_ORDER.get(right.name);
403
+ if (leftBuiltInOrder !== undefined || rightBuiltInOrder !== undefined) {
404
+ if (leftBuiltInOrder === undefined) return 1;
405
+ if (rightBuiltInOrder === undefined) return -1;
406
+ return leftBuiltInOrder - rightBuiltInOrder;
407
+ }
408
+ return left.name.localeCompare(right.name);
409
+ }
410
+
411
+ function normalizeCatalogDescription(description: string, maxLength: number): string {
412
+ const normalized = description.replace(/\s+/gu, " ").trim();
413
+ if (normalized.length <= maxLength) return normalized;
414
+ const suffix = "…";
415
+ return `${normalized.slice(0, Math.max(0, maxLength - suffix.length)).trimEnd()}${suffix}`;
416
+ }
417
+
418
+ type CatalogScope = "user" | "project" | "project-fallback";
419
+
420
+ function catalogAgentLine(
421
+ agent: AgentConfig,
422
+ scope: CatalogScope,
423
+ userNames: ReadonlySet<string>,
424
+ maxDescriptionLength: number,
425
+ ): string {
426
+ const scopeLabel =
427
+ scope === "user"
428
+ ? 'agentScope: "user"'
429
+ : scope === "project"
430
+ ? 'requires agentScope: "project" or "both"'
431
+ : 'requires agentScope: "project" ("both" selects the user definition)';
432
+ const collision =
433
+ scope !== "user" && userNames.has(agent.name)
434
+ ? scope === "project"
435
+ ? "; overrides the default user definition for project/both"
436
+ : "; scope-specific fallback for the default user override"
437
+ : "";
438
+ return `- ${agent.name} [source: ${agent.source}; ${scopeLabel}${collision}] — ${normalizeCatalogDescription(agent.description, maxDescriptionLength)}`;
439
+ }
440
+
441
+ /**
442
+ * Format the effective agent variants that the parent model can invoke.
443
+ *
444
+ * User-authored descriptions are prompt text, so this formatter deliberately normalizes and bounds
445
+ * them. Project definitions are supplied separately by the caller so an untrusted project is never
446
+ * read merely to build model-facing metadata.
447
+ */
448
+ export function formatAgentCatalog(
449
+ catalog: AgentCatalog,
450
+ options: AgentCatalogFormatOptions = {},
451
+ ): AgentCatalogFormatResult {
452
+ const maxItems = Math.max(0, options.maxItems ?? DEFAULT_AGENT_CATALOG_MAX_ITEMS);
453
+ const maxDescriptionLength = Math.max(
454
+ 1,
455
+ options.maxDescriptionLength ?? DEFAULT_AGENT_CATALOG_MAX_DESCRIPTION_LENGTH,
456
+ );
457
+ const maxCharacters = Math.max(1, options.maxCharacters ?? DEFAULT_AGENT_CATALOG_MAX_CHARACTERS);
458
+ const userDiscoveryIncomplete =
459
+ (catalog.user.omittedAgentDefinitions ?? 0) > 0 ||
460
+ catalog.user.metadataDiscoveryIncomplete === true;
461
+ const projectDiscoveryIncomplete =
462
+ (catalog.project?.omittedAgentDefinitions ?? 0) > 0 ||
463
+ catalog.project?.metadataDiscoveryIncomplete === true;
464
+ const discoveredUserAgents = [...catalog.user.agents].sort(compareCatalogAgents);
465
+ const discoveredProjectScopeAgents = [...(catalog.project?.agents ?? [])].sort(
466
+ compareCatalogAgents,
467
+ );
468
+ const userAgents = userDiscoveryIncomplete ? [] : discoveredUserAgents;
469
+ const projectScopeAgents = projectDiscoveryIncomplete ? [] : discoveredProjectScopeAgents;
470
+ const projectAgents = projectScopeAgents.filter((agent) => agent.source === "project");
471
+ const discoveredUserByName = new Map(discoveredUserAgents.map((agent) => [agent.name, agent]));
472
+ const userByName = new Map(userAgents.map((agent) => [agent.name, agent]));
473
+ const userNames = new Set(userByName.keys());
474
+ const potentialProjectFallbackAgents = discoveredProjectScopeAgents.filter(
475
+ (agent) =>
476
+ agent.source === "built-in" && discoveredUserByName.get(agent.name)?.source === "user",
477
+ );
478
+ const projectFallbackAgents =
479
+ userDiscoveryIncomplete || projectDiscoveryIncomplete ? [] : potentialProjectFallbackAgents;
480
+ const allEntries = [
481
+ ...userAgents.map((agent) => ({ agent, scope: "user" as const })),
482
+ ...projectAgents.map((agent) => ({ agent, scope: "project" as const })),
483
+ ...projectFallbackAgents.map((agent) => ({ agent, scope: "project-fallback" as const })),
484
+ ];
485
+ const boundedEntries = allEntries.slice(0, maxItems);
486
+ const suppressedMetadataEntries =
487
+ (userDiscoveryIncomplete ? discoveredUserAgents.length : 0) +
488
+ (projectDiscoveryIncomplete
489
+ ? discoveredProjectScopeAgents.filter((agent) => agent.source === "project").length +
490
+ potentialProjectFallbackAgents.length
491
+ : 0);
492
+ const discoveryOmissions =
493
+ (catalog.user.omittedAgentDefinitions ?? 0) +
494
+ (catalog.project?.omittedAgentDefinitions ?? 0) +
495
+ suppressedMetadataEntries;
496
+ const discoveryIncomplete =
497
+ catalog.user.metadataDiscoveryIncomplete === true ||
498
+ catalog.project?.metadataDiscoveryIncomplete === true;
499
+
500
+ const render = (entries: typeof allEntries, omitted: number): string => {
501
+ const lines = [
502
+ "Available agent definitions (metadata only; runtime validation and trust remain authoritative).",
503
+ ];
504
+ const userLines = entries
505
+ .filter((entry) => entry.scope === "user")
506
+ .map((entry) => catalogAgentLine(entry.agent, entry.scope, userNames, maxDescriptionLength));
507
+ if (userLines.length > 0) {
508
+ lines.push('Default scope (agentScope: "user"):');
509
+ lines.push(...userLines);
510
+ }
511
+ const projectLines = entries
512
+ .filter((entry) => entry.scope !== "user")
513
+ .map((entry) => catalogAgentLine(entry.agent, entry.scope, userNames, maxDescriptionLength));
514
+ if (projectLines.length > 0) {
515
+ lines.push("Trusted project/scope variants (use the required agentScope shown):");
516
+ lines.push(...projectLines);
517
+ }
518
+ const collisionNames = entries
519
+ .filter((entry) => entry.scope !== "user" && userNames.has(entry.agent.name))
520
+ .map((entry) => entry.agent.name);
521
+ if (collisionNames.length > 0 && projectLines.length > 0) {
522
+ const precedence = entries
523
+ .filter((entry) => entry.scope !== "user" && userNames.has(entry.agent.name))
524
+ .map((entry) =>
525
+ entry.scope === "project"
526
+ ? `${entry.agent.name}: user with "user", project with "project"/"both"`
527
+ : `${entry.agent.name}: user with "user"/"both", built-in with "project"`,
528
+ );
529
+ lines.push(`Same-name precedence: ${precedence.join("; ")}.`);
530
+ }
531
+ if (omitted > 0) {
532
+ lines.push(
533
+ `[${omitted} additional agent definition${omitted === 1 ? "" : "s"} omitted due to metadata bounds or incomplete discovery.]`,
534
+ );
535
+ }
536
+ if (discoveryIncomplete) {
537
+ lines.push("[Agent metadata discovery was incomplete; some definitions may be unavailable.]");
538
+ }
539
+ return lines.join("\n");
540
+ };
541
+
542
+ let listedCount = boundedEntries.length;
543
+ let text = render(
544
+ boundedEntries.slice(0, listedCount),
545
+ allEntries.length - listedCount + discoveryOmissions,
546
+ );
547
+ while (text.length > maxCharacters && listedCount > 0) {
548
+ listedCount -= 1;
549
+ text = render(
550
+ boundedEntries.slice(0, listedCount),
551
+ allEntries.length - listedCount + discoveryOmissions,
552
+ );
553
+ }
554
+ return { text, omitted: allEntries.length - listedCount + discoveryOmissions };
555
+ }
556
+
557
+ export function discoverAgentCatalog(
558
+ cwd: string,
559
+ projectTrusted: boolean,
560
+ config?: SubagentSettings,
561
+ ): AgentCatalog {
562
+ const options: AgentDiscoveryOptions = {
563
+ maxFiles: DEFAULT_AGENT_CATALOG_MAX_FILES_PER_SCOPE,
564
+ maxFileBytes: DEFAULT_AGENT_CATALOG_MAX_FILE_BYTES,
565
+ maxTotalBytes: DEFAULT_AGENT_CATALOG_MAX_TOTAL_BYTES_PER_SCOPE,
566
+ };
567
+ return {
568
+ user: discoverAgents(cwd, "user", config, options),
569
+ project: projectTrusted ? discoverAgents(cwd, "project", config, options) : undefined,
570
+ };
571
+ }
package/src/stateful.ts CHANGED
@@ -106,6 +106,7 @@ export interface StatefulSubagentRuntimeStatus {
106
106
  export interface StatefulSubagentController {
107
107
  getCompletionDelivery(): CompletionDelivery;
108
108
  setCompletionDelivery(value: CompletionDelivery): void;
109
+ setAgentCatalog(value: string): void;
109
110
  getRuntimeStatus(): StatefulSubagentRuntimeStatus;
110
111
  listAgents(includeClosed?: boolean): ManagedAgent[];
111
112
  clearAgents(): Promise<number>;
@@ -127,6 +128,7 @@ export function registerStatefulSubagents(
127
128
  const enabled = settings.enabled !== false;
128
129
  const transportKind = resolveStatefulTransportKind(settings.transport);
129
130
  let completionDelivery = resolveCompletionDelivery(settings.completionDelivery);
131
+ let agentCatalog = "";
130
132
  let completionBroker: CompletionDeliveryBroker | undefined;
131
133
  let refreshSpawnToolRegistration: (() => void) | undefined;
132
134
  let registry: AgentRegistry | undefined;
@@ -161,6 +163,10 @@ export function registerStatefulSubagents(
161
163
  completionBroker?.setDelivery(value);
162
164
  refreshSpawnToolRegistration?.();
163
165
  },
166
+ setAgentCatalog(value) {
167
+ agentCatalog = value;
168
+ refreshSpawnToolRegistration?.();
169
+ },
164
170
  getRuntimeStatus() {
165
171
  const agents = registry?.list(true) ?? [];
166
172
  return {
@@ -315,11 +321,12 @@ export function registerStatefulSubagents(
315
321
  }
316
322
  });
317
323
 
324
+ const baseSpawnDescription =
325
+ "Start an addressable background subagent with an optional thinking level chosen for the task difficulty, return immediately with an agentId, and receive its completion asynchronously.";
318
326
  const spawnTool = defineTool({
319
327
  name: "subagent_spawn",
320
328
  label: "Spawn Subagent",
321
- description:
322
- "Start an addressable background subagent with an optional thinking level chosen for the task difficulty, return immediately with an agentId, and receive its completion asynchronously.",
329
+ description: appendAgentCatalog(baseSpawnDescription, agentCatalog),
323
330
  promptSnippet: "Start a reusable detached subagent; completion is delivered asynchronously",
324
331
  promptGuidelines: createSpawnPromptGuidelines(completionDelivery, blockingEnabled),
325
332
  parameters: Type.Object({
@@ -399,6 +406,7 @@ export function registerStatefulSubagents(
399
406
  },
400
407
  });
401
408
  refreshSpawnToolRegistration = () => {
409
+ spawnTool.description = appendAgentCatalog(baseSpawnDescription, agentCatalog);
402
410
  spawnTool.promptGuidelines = createSpawnPromptGuidelines(completionDelivery, blockingEnabled);
403
411
  pi.registerTool(spawnTool);
404
412
  };
@@ -917,6 +925,10 @@ async function cleanupClosedWorkspaces(
917
925
  }
918
926
  }
919
927
 
928
+ function appendAgentCatalog(baseDescription: string, catalog: string): string {
929
+ return catalog ? `${baseDescription}\n\n${catalog}` : baseDescription;
930
+ }
931
+
920
932
  function result(agent: ManagedAgent, text: string) {
921
933
  return {
922
934
  content: [{ type: "text" as const, text }],
package/src/subagents.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  * Uses JSON mode to capture structured output from subagents.
13
13
  */
14
14
 
15
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
16
+ import { discoverAgentCatalog, formatAgentCatalog } from "./agents.js";
16
17
  import { registerSubagentConfigCommand } from "./config-ui.js";
17
18
  import { executeSubagent } from "./execution.js";
18
19
  import { SubagentParams } from "./params.js";
@@ -23,43 +24,53 @@ import { registerStatefulSubagents } from "./stateful.js";
23
24
 
24
25
  export default function (pi: ExtensionAPI) {
25
26
  const settings = readSubagentSettings();
26
- if (settings?.blocking?.enabled !== false) registerBlockingSubagent(pi);
27
+ const blockingEnabled = settings?.blocking?.enabled !== false;
28
+ const refreshBlockingCatalog = blockingEnabled ? registerBlockingSubagent(pi) : () => undefined;
29
+ let refreshStatefulCatalog: (catalog: string) => void = () => undefined;
27
30
 
28
31
  pi.on("session_start", (_event, ctx) => {
29
32
  // Preserve a one-shot migration notice from extension load while refreshing
30
33
  // validation against settings that may have changed before this session.
31
34
  const loadNotice = consumeSubagentSettingsNotice();
32
- readSubagentSettings();
35
+ const refreshedSettings = readSubagentSettings();
33
36
  const refreshedNotice = consumeSubagentSettingsNotice();
34
37
  const notice = [
35
38
  ...new Set([loadNotice, refreshedNotice].filter((value) => value !== undefined)),
36
39
  ].join("\n");
37
40
  if (notice) ctx.ui.notify(notice, "warning");
41
+
42
+ const catalog = formatAgentCatalog(
43
+ discoverAgentCatalog(ctx.cwd, ctx.isProjectTrusted(), refreshedSettings),
44
+ ).text;
45
+ refreshBlockingCatalog(catalog);
46
+ refreshStatefulCatalog(catalog);
38
47
  });
39
48
 
40
- const blockingEnabled = settings?.blocking?.enabled !== false;
41
49
  const statefulRuntime = registerStatefulSubagents(pi, {
42
50
  blockingEnabled,
43
51
  settings: settings?.stateful,
44
52
  });
53
+ refreshStatefulCatalog = statefulRuntime.setAgentCatalog;
45
54
  registerSubagentConfigCommand(pi, {
46
55
  ...statefulRuntime,
47
56
  getBlockingEnabled: () => blockingEnabled,
48
57
  });
49
58
  }
50
59
 
51
- function registerBlockingSubagent(pi: ExtensionAPI) {
52
- pi.registerTool<typeof SubagentParams, SubagentDetails>({
60
+ function registerBlockingSubagent(pi: ExtensionAPI): (catalog: string) => void {
61
+ let catalog = "";
62
+ const baseDescription = [
63
+ "Run specialized subagents as a blocking operation with isolated contexts.",
64
+ "The call blocks the main agent until every worker and optional aggregator finishes, so queued steering waits.",
65
+ "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
66
+ "Parallel mode may include an aggregator fan-in step that receives all task outputs.",
67
+ 'Default agent scope is "user" (from ~/.pi/agent/agents).',
68
+ 'To enable project-local agents in .pi/agents, pass agentScope: "both" (or "project") as a top-level argument for that call.',
69
+ ].join(" ");
70
+ const definition: ToolDefinition<typeof SubagentParams, SubagentDetails> = {
53
71
  name: "subagent",
54
72
  label: "Blocking Subagent",
55
- description: [
56
- "Run specialized subagents as a blocking operation with isolated contexts.",
57
- "The call blocks the main agent until every worker and optional aggregator finishes, so queued steering waits.",
58
- "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
59
- "Parallel mode may include an aggregator fan-in step that receives all task outputs.",
60
- 'Default agent scope is "user" (from ~/.pi/agent/agents).',
61
- 'To enable project-local agents in .pi/agents, pass agentScope: "both" (or "project") as a top-level argument for that call.',
62
- ].join(" "),
73
+ description: appendAgentCatalog(baseDescription, catalog),
63
74
  promptSnippet:
64
75
  "Run blocking isolated subagents only when their outputs are required before the main agent can continue.",
65
76
  promptGuidelines: [
@@ -85,13 +96,22 @@ function registerBlockingSubagent(pi: ExtensionAPI) {
85
96
  renderResult(result, options, theme) {
86
97
  return renderSubagentResult(result, options, theme);
87
98
  },
88
- });
89
-
99
+ };
100
+ pi.registerTool<typeof SubagentParams, SubagentDetails>(definition);
90
101
  pi.on("tool_result", (event) => {
91
102
  if (event.toolName !== "subagent") return;
92
103
  if ((event.details as (SubagentDetails & { isError?: boolean }) | undefined)?.isError)
93
104
  return { isError: true };
94
105
  });
106
+ return (nextCatalog: string) => {
107
+ catalog = nextCatalog;
108
+ definition.description = appendAgentCatalog(baseDescription, catalog);
109
+ pi.registerTool<typeof SubagentParams, SubagentDetails>(definition);
110
+ };
111
+ }
112
+
113
+ function appendAgentCatalog(baseDescription: string, catalog: string): string {
114
+ return catalog ? `${baseDescription}\n\n${catalog}` : baseDescription;
95
115
  }
96
116
 
97
117
  export { parsePositiveInteger } from "./execution.js";