pi-subagents 0.29.0 → 0.30.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
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.30.0] - 2026-06-20
6
+
7
+ ### Added
8
+ - Allow active async chains to accept an `append-step` request that adds one new tail step while the chain is still running.
9
+ - Allow async subagent results to be attached as the root step of a new follow-up chain.
10
+ - Added `subagentOnlyExtensions` so agents can pass selected tool extensions only to spawned subagents without exposing them to the parent agent.
11
+ - Added proactive skill-subagent suggestions to `subagent({ action: "list" })` based on repeatedly configured skill use, while keeping the behavior advisory and opt-out friendly.
12
+ - Added regression coverage for long worker/reviewer chains and parallel -> funnel -> fanout chain flows across foreground and async execution.
13
+
14
+ ### Fixed
15
+ - Interrupt live async children before delivering `resume` follow-up messages so intercom nudges reach workers that are stuck mid-turn more reliably.
16
+ - Reject appended chain steps with duplicate reserved output names or unknown named-output references before they are queued.
17
+ - Ignore legacy `.agents/skills` files during agent discovery so skill definitions are not registered as subagents. Thanks to chyax98 (@chyax98) for #257.
18
+ - Launch detached async runners through Node when Pi itself is not the Node executable. Thanks to Tetsuya.dev (@tetsuya-dev-jp) for #273.
19
+ - Preserve the slash command requester context when bridge requests launch subagents. Thanks to Victor Sumner (@vsumner) for #268.
20
+ - Trim repeated nested `subagent` tool schema descriptions so provider payloads stay compact while retaining top-level parameter guidance. Thanks to Thomas Mustier (@tmustier) for #250.
21
+
5
22
  ## [0.29.0] - 2026-06-19
6
23
 
7
24
  ### Added
package/README.md CHANGED
@@ -424,6 +424,7 @@ package: code-analysis
424
424
  description: Fast codebase recon
425
425
  tools: read, grep, find, ls, bash, mcp:chrome-devtools
426
426
  extensions:
427
+ subagentOnlyExtensions: ./tools/child-only-search.ts
427
428
  model: claude-haiku-4-5
428
429
  fallbackModels: openai/gpt-5-mini, anthropic/claude-sonnet-4
429
430
  thinking: high
@@ -449,6 +450,7 @@ Important fields:
449
450
  | `package` | Optional package identifier. A file with `name: scout` and `package: code-analysis` registers as `code-analysis.scout`; serialization keeps `name` and `package` separate. |
450
451
  | `tools` | Builtin tool allowlist. `mcp:` entries select direct MCP tools when `pi-mcp-adapter` is installed. |
451
452
  | `extensions` | Omitted means normal extensions; empty means no extensions; comma-separated values allowlist specific extensions. |
453
+ | `subagentOnlyExtensions` | Comma-separated extension paths loaded only in spawned child sessions for this agent. Tools registered there are unavailable to the main agent unless also installed through normal Pi extension configuration. |
452
454
  | `model` | Default model. Bare ids prefer the current provider when possible, then unique registry matches. |
453
455
  | `fallbackModels` | Ordered backup models for provider/model failures such as quota, auth, timeout, or unavailable model. Ordinary task failures do not trigger fallback. |
454
456
  | `thinking` | Appended as a `:level` suffix at runtime unless a suffix is already present. |
@@ -491,6 +493,8 @@ extensions: /abs/path/to/ext-a.ts, /abs/path/to/ext-b.ts
491
493
 
492
494
  When `extensions` is present, it takes precedence over extension paths implied by `tools` entries.
493
495
 
496
+ Use `subagentOnlyExtensions` when a custom extension tool should exist only inside child sessions. It is scoped by agent config: every run of that agent receives those extension paths, while other agents do not unless they declare the same field. The current model does not have a separate named-subagent audience inside one agent definition.
497
+
494
498
  ## Chain files
495
499
 
496
500
  Chains are reusable workflows stored separately from agent files. Use `.chain.md` for simple sequential saved chains. Use `.chain.json` when a chain needs dynamic fanout.
@@ -786,7 +790,7 @@ Agent definitions are not loaded into context by default. Management actions let
786
790
  |-------|------|---------|-------------|
787
791
  | `agent` | string | - | Agent name for single mode, or target for management actions. |
788
792
  | `task` | string | - | Task string for single mode. |
789
- | `action` | string | - | `list`, `get`, `create`, `update`, `delete`, `status`, `interrupt`, `resume`, or `doctor`. |
793
+ | `action` | string | - | `list`, `get`, `create`, `update`, `delete`, `status`, `interrupt`, `resume`, `append-step`, or `doctor`. |
790
794
  | `chainName` | string | - | Chain name for management actions. |
791
795
  | `config` | object/string | - | Agent or chain config for create/update. |
792
796
  | `output` | `string \| false` | agent default | Override single-agent output file. |
@@ -796,7 +800,7 @@ Agent definitions are not loaded into context by default. Management actions let
796
800
  | `tasks` | array | - | Top-level parallel tasks. Supports `agent`, `task`, `cwd`, `count`, `output`, `outputMode`, `reads`, `progress`, `skill`, `model`, and `acceptance`. |
797
801
  | `concurrency` | number | config or `4` | Top-level parallel concurrency. |
798
802
  | `worktree` | boolean | false | Create isolated git worktrees for parallel tasks. |
799
- | `chain` | array | - | Sequential, static parallel, and dynamic fanout chain steps. Steps and chain parallel tasks support `phase`, `label`, `as`, `outputSchema`, and `acceptance` in addition to the usual execution fields. Dynamic fanout uses `expand`, one child `parallel` template, and `collect`. |
803
+ | `chain` | array | - | Sequential, static parallel, and dynamic fanout chain steps. Steps and chain parallel tasks support `phase`, `label`, `as`, `outputSchema`, and `acceptance` in addition to the usual execution fields. Dynamic fanout uses `expand`, one child `parallel` template, and `collect`. With `action: "append-step"`, pass exactly one step to append to a running async chain. |
800
804
  | `context` | `fresh \| fork` | agent default or `fresh` | `fork` creates real branched sessions from the parent leaf. Packaged `planner`, `worker`, and `oracle` default to `fork`. |
801
805
  | `chainDir` | string | temp chain dir | Persistent directory for chain artifacts. |
802
806
  | `clarify` | boolean | true for chains | Show TUI preview/edit flow. |
@@ -827,6 +831,7 @@ subagent({ action: "interrupt", id: "<nested-run-id>" })
827
831
  subagent({ action: "resume", id: "<run-id>", message: "follow-up question" })
828
832
  subagent({ action: "resume", id: "<run-id>", index: 1, message: "follow-up for child 2" })
829
833
  subagent({ action: "resume", id: "<nested-run-id>", message: "follow-up for a nested child" })
834
+ subagent({ action: "append-step", id: "<run-id>", chain: [{ agent: "worker", task: "Continue from {previous}" }] })
830
835
  subagent({ action: "doctor" })
831
836
  ```
832
837
 
@@ -834,6 +839,8 @@ subagent({ action: "doctor" })
834
839
 
835
840
  `resume` sends the follow-up directly when an async child is still reachable over intercom. After completion, it revives the child by starting a new async child from the stored child session file. Multi-child async runs and remembered foreground single, parallel, or chain runs can be revived by passing `index` to choose the child. Nested runs can be resumed by nested id when their live route or persisted session metadata is available. Revive starts a new child process from the old session context; it does not restart the same OS process, and it requires the chosen child to have a persisted `.jsonl` session file.
836
841
 
842
+ `append-step` accepts exactly one sequential, static parallel, or dynamic fanout chain step for a top-level async chain whose status is still `running`. The step is persisted in the run directory and becomes eligible only after the chain's already-queued steps finish; completed, failed, paused, foreground, single, and top-level parallel runs reject appends.
843
+
837
844
  ## Worktree isolation
838
845
 
839
846
  Parallel agents can clobber each other if they edit the same checkout. `worktree: true` gives each parallel child its own git worktree branched from `HEAD`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -20,6 +20,7 @@ Use this skill when the parent orchestrator needs to launch a specialized subage
20
20
  - **Implementation handoff**: have `oracle` advise, then `worker` implement only after an approved direction
21
21
  - **Recon and planning**: use `scout` or `context-builder`, then `planner`
22
22
  - **Parallel exploration**: run multiple non-conflicting tasks concurrently
23
+ - **Regular skill specialists**: when discovery shows proactive skill subagent suggestions and the current work is broad enough, launch a small fresh-context fanout that asks one subagent per relevant regularly used skill to apply that skill's perspective to the task
23
24
  - **Long-running work**: launch async/background runs and inspect them later
24
25
  - **Subagent control**: watch needs-attention signals and soft-interrupt only when a delegated run is genuinely blocked
25
26
  - **Agent authoring**: create, update, or override agents and chains for a project
@@ -55,6 +56,30 @@ The prompt templates in `prompts/` encode workflows the parent agent can run on
55
56
 
56
57
  Use this when the user wants adversarial review of a diff, plan, issue, file, or implemented work. Launch fresh-context `reviewer` agents with distinct angles generated from the actual target. Common angles are correctness/regressions, tests/validation, and simplicity/maintainability; adapt for TypeScript, UI, security, docs, or large structural changes. Reviewers should inspect files and diffs directly, return concise evidence-backed findings with file/line references, and avoid edits unless the user explicitly asks for a writer pass. The parent synthesizes fixes worth doing now, optional improvements, and feedback to ignore/defer before applying anything.
57
58
 
59
+ ### Proactive skill-specialist technique
60
+
61
+ Use this when `{ action: "list" }` reports proactive skill subagent suggestions and the user's task would benefit from perspectives the parent regularly uses. These suggestions are conservative: a skill is recommended only when it is available and referenced repeatedly by configured agents or saved chains. Treat the list as an opt-in hint for the current task, not a command to always fan out.
62
+
63
+ Default guardrails:
64
+ - Keep the fanout small: usually one or two skill-specialist children, never more than the listed recommendations or configured cap.
65
+ - Prefer `context: "fresh"` and include only the files, diff, plan, URL, or request details each child needs. Use forked context only when private/session history is essential and appropriate to share.
66
+ - Use read-only agents for analysis/review unless implementation was explicitly requested; do not create several writers in the same worktree.
67
+ - Skip proactive skill subagents for tiny questions, direct commands, highly private requests, or when the user asks not to delegate.
68
+ - Make cost and concurrency visible by using an ordinary `subagent(...)` call rather than hidden/background automation.
69
+
70
+ Example shape:
71
+
72
+ ```typescript
73
+ subagent({
74
+ tasks: [
75
+ { agent: "reviewer", task: "Apply the available 'deslop' skill to review the current diff for concrete cleanup findings only. Do not modify files.", skill: "deslop" },
76
+ { agent: "reviewer", task: "Apply the available 'accessibility' skill to review the UI changes for concrete issues only. Do not modify files.", skill: "accessibility" }
77
+ ],
78
+ context: "fresh",
79
+ concurrency: 2
80
+ })
81
+ ```
82
+
58
83
  ### Review-loop technique
59
84
 
60
85
  Use this when the user wants implementation or current diff review to continue until reviewers stop finding fixes worth doing now. Keep the loop in the parent session: one async `worker` implements or fixes, fresh-context `reviewer` agents inspect the actual repo and diff, the parent synthesizes accepted fixes, and one async forked `worker` applies them. The parent can express the sequence up front as an async/background chain when the workflow is known, or continue with explicit follow-up subagent runs after each async completion. For an initial chain, pass `async: true` so the main chat is unblocked; do not set `clarify: true` unless the user explicitly wants the foreground clarify UI. Treat an async implementation worker handoff as an intermediate state, not final completion, unless the user explicitly asked for worker-only work, review-only output, or to stop after implementation. Stop when reviewers find no blockers or fixes worth doing now, remaining feedback is optional or deferred, an unapproved product/scope/architecture decision appears, or the max review-round cap is reached. Default to 3 review rounds unless the user sets a different cap. Do not loop for optional polish, and do not let children launch subagents or decide the loop outcome.
@@ -19,11 +19,14 @@ import {
19
19
  import { serializeAgent } from "./agent-serializer.ts";
20
20
  import { serializeChain, serializeJsonChain } from "./chain-serializer.ts";
21
21
  import { discoverAvailableSkills } from "./skills.ts";
22
- import type { Details } from "../shared/types.ts";
22
+ import {
23
+ buildProactiveSkillSubagentRecommendationLines,
24
+ } from "./proactive-skills.ts";
25
+ import type { Details, ExtensionConfig } from "../shared/types.ts";
23
26
 
24
27
  type ManagementAction = "list" | "get" | "create" | "update" | "delete";
25
28
  type ManagementScope = "user" | "project";
26
- type ManagementContext = Pick<ExtensionContext, "cwd" | "modelRegistry">;
29
+ type ManagementContext = Pick<ExtensionContext, "cwd" | "modelRegistry"> & { config?: ExtensionConfig };
27
30
 
28
31
  interface ManagementParams {
29
32
  action?: string;
@@ -273,6 +276,12 @@ function applyAgentConfig(target: AgentConfig, cfg: Record<string, unknown>): st
273
276
  else if (typeof cfg.extensions === "string") target.extensions = parseCsv(cfg.extensions);
274
277
  else return "config.extensions must be a comma-separated string, empty string, or false when provided.";
275
278
  }
279
+ if (hasKey(cfg, "subagentOnlyExtensions")) {
280
+ if (cfg.subagentOnlyExtensions === false) target.subagentOnlyExtensions = undefined;
281
+ else if (cfg.subagentOnlyExtensions === "") target.subagentOnlyExtensions = [];
282
+ else if (typeof cfg.subagentOnlyExtensions === "string") target.subagentOnlyExtensions = parseCsv(cfg.subagentOnlyExtensions);
283
+ else return "config.subagentOnlyExtensions must be a comma-separated string, empty string, or false when provided.";
284
+ }
276
285
  if (hasKey(cfg, "thinking")) {
277
286
  if (cfg.thinking === false || cfg.thinking === "") target.thinking = undefined;
278
287
  else if (typeof cfg.thinking === "string") target.thinking = cfg.thinking.trim() || undefined;
@@ -385,6 +394,7 @@ function formatAgentDetail(agent: AgentConfig): string {
385
394
  if (agent.defaultContext) lines.push(`Default context: ${agent.defaultContext}`);
386
395
  if (agent.source === "builtin") lines.push(`Disabled: ${agent.disabled ? "true" : "false"}`);
387
396
  if (agent.extensions !== undefined) lines.push(`Extensions: ${agent.extensions.length ? agent.extensions.join(", ") : "(none)"}`);
397
+ if (agent.subagentOnlyExtensions !== undefined) lines.push(`Subagent-only extensions: ${agent.subagentOnlyExtensions.length ? agent.subagentOnlyExtensions.join(", ") : "(none)"}`);
388
398
  if (agent.thinking) lines.push(`Thinking: ${agent.thinking}`);
389
399
  if (agent.output) lines.push(`Output: ${agent.output}`);
390
400
  if (agent.defaultReads?.length) lines.push(`Reads: ${agent.defaultReads.join(", ")}`);
@@ -450,6 +460,12 @@ export function handleList(params: ManagementParams, ctx: ManagementContext): Ag
450
460
  const agents = scopedAgents.filter((a) => !a.disabled);
451
461
  const chains = d.chains.filter((c) => scope === "both" || c.source === "package" || c.source === scope).sort((a, b) => a.name.localeCompare(b.name));
452
462
  const diagnostics = d.chainDiagnostics.filter((entry) => scope === "both" || entry.source === scope);
463
+ const proactiveSuggestions = buildProactiveSkillSubagentRecommendationLines({
464
+ agents,
465
+ chains,
466
+ config: ctx.config?.proactiveSkillSubagents,
467
+ discoverAvailableSkills: () => discoverAvailableSkills(ctx.cwd),
468
+ });
453
469
  const lines = [
454
470
  "Executable agents:",
455
471
  ...(agents.length
@@ -458,6 +474,7 @@ export function handleList(params: ManagementParams, ctx: ManagementContext): Ag
458
474
  "",
459
475
  "Chains:",
460
476
  ...(chains.length ? chains.map((c) => `- ${c.name} (${c.source}): ${c.description}`) : ["- (none)"]),
477
+ ...(proactiveSuggestions.length ? ["", ...proactiveSuggestions] : []),
461
478
  ...(diagnostics.length ? ["", "Chain diagnostics:", ...diagnostics.map((entry) => `- ${entry.filePath}: ${entry.error}`)] : []),
462
479
  ];
463
480
  return result(lines.join("\n"));
@@ -16,6 +16,7 @@ export const KNOWN_FIELDS = new Set([
16
16
  "skill",
17
17
  "skills",
18
18
  "extensions",
19
+ "subagentOnlyExtensions",
19
20
  "output",
20
21
  "defaultReads",
21
22
  "defaultProgress",
@@ -59,6 +60,10 @@ export function serializeAgent(config: AgentConfig): string {
59
60
  const extensionsValue = joinComma(config.extensions);
60
61
  lines.push(`extensions: ${extensionsValue ?? ""}`);
61
62
  }
63
+ if (config.subagentOnlyExtensions !== undefined) {
64
+ const subagentOnlyExtensionsValue = joinComma(config.subagentOnlyExtensions);
65
+ lines.push(`subagentOnlyExtensions: ${subagentOnlyExtensionsValue ?? ""}`);
66
+ }
62
67
 
63
68
  if (config.output) lines.push(`output: ${config.output}`);
64
69
 
@@ -47,6 +47,7 @@ export interface BuiltinAgentOverrideBase {
47
47
  skills?: string[];
48
48
  tools?: string[];
49
49
  mcpDirectTools?: string[];
50
+ subagentOnlyExtensions?: string[];
50
51
  completionGuard?: boolean;
51
52
  }
52
53
 
@@ -62,6 +63,7 @@ interface BuiltinAgentOverrideConfig {
62
63
  systemPrompt?: string;
63
64
  skills?: string[] | false;
64
65
  tools?: string[] | false;
66
+ subagentOnlyExtensions?: string[] | false;
65
67
  completionGuard?: boolean;
66
68
  }
67
69
 
@@ -90,6 +92,7 @@ export interface AgentConfig {
90
92
  filePath: string;
91
93
  skills?: string[];
92
94
  extensions?: string[];
95
+ subagentOnlyExtensions?: string[];
93
96
  output?: string;
94
97
  defaultReads?: string[];
95
98
  defaultProgress?: boolean;
@@ -457,6 +460,7 @@ function cloneOverrideBase(agent: AgentConfig): BuiltinAgentOverrideBase {
457
460
  skills: agent.skills ? [...agent.skills] : undefined,
458
461
  tools: agent.tools ? [...agent.tools] : undefined,
459
462
  mcpDirectTools: agent.mcpDirectTools ? [...agent.mcpDirectTools] : undefined,
463
+ subagentOnlyExtensions: agent.subagentOnlyExtensions ? [...agent.subagentOnlyExtensions] : undefined,
460
464
  completionGuard: agent.completionGuard,
461
465
  };
462
466
  }
@@ -476,6 +480,7 @@ function cloneOverrideValue(override: BuiltinAgentOverrideConfig): BuiltinAgentO
476
480
  ...(override.systemPrompt !== undefined ? { systemPrompt: override.systemPrompt } : {}),
477
481
  ...(override.skills !== undefined ? { skills: override.skills === false ? false : [...override.skills] } : {}),
478
482
  ...(override.tools !== undefined ? { tools: override.tools === false ? false : [...override.tools] } : {}),
483
+ ...(override.subagentOnlyExtensions !== undefined ? { subagentOnlyExtensions: override.subagentOnlyExtensions === false ? false : [...override.subagentOnlyExtensions] } : {}),
479
484
  ...(override.completionGuard !== undefined ? { completionGuard: override.completionGuard } : {}),
480
485
  };
481
486
  }
@@ -635,6 +640,9 @@ function parseBuiltinOverrideEntry(
635
640
  const tools = parseOverrideStringArrayOrFalse(input.tools, { filePath, name, field: "tools" });
636
641
  if (tools !== undefined) override.tools = tools;
637
642
 
643
+ const subagentOnlyExtensions = parseOverrideStringArrayOrFalse(input.subagentOnlyExtensions, { filePath, name, field: "subagentOnlyExtensions" });
644
+ if (subagentOnlyExtensions !== undefined) override.subagentOnlyExtensions = subagentOnlyExtensions;
645
+
638
646
  return Object.keys(override).length > 0 ? override : undefined;
639
647
  }
640
648
 
@@ -693,6 +701,9 @@ function applyBuiltinOverride(
693
701
  next.tools = tools;
694
702
  next.mcpDirectTools = mcpDirectTools;
695
703
  }
704
+ if (override.subagentOnlyExtensions !== undefined) {
705
+ next.subagentOnlyExtensions = override.subagentOnlyExtensions === false ? undefined : [...override.subagentOnlyExtensions];
706
+ }
696
707
  if (override.completionGuard !== undefined) next.completionGuard = override.completionGuard;
697
708
 
698
709
  return next;
@@ -733,7 +744,7 @@ function applyBuiltinOverrides(
733
744
 
734
745
  export function buildBuiltinOverrideConfig(
735
746
  base: BuiltinAgentOverrideBase,
736
- draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "completionGuard">,
747
+ draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "subagentOnlyExtensions" | "completionGuard">,
737
748
  ): BuiltinAgentOverrideConfig | undefined {
738
749
  const override: BuiltinAgentOverrideConfig = {};
739
750
 
@@ -751,6 +762,9 @@ export function buildBuiltinOverrideConfig(
751
762
  const baseTools = joinToolList(base);
752
763
  const draftTools = joinToolList(draft);
753
764
  if (!arraysEqual(draftTools, baseTools)) override.tools = draftTools ? [...draftTools] : false;
765
+ if (!arraysEqual(draft.subagentOnlyExtensions, base.subagentOnlyExtensions)) {
766
+ override.subagentOnlyExtensions = draft.subagentOnlyExtensions ? [...draft.subagentOnlyExtensions] : false;
767
+ }
754
768
  if ((draft.completionGuard !== false) !== (base.completionGuard !== false)) {
755
769
  override.completionGuard = draft.completionGuard !== false;
756
770
  }
@@ -830,10 +844,23 @@ function listFilesRecursive(dir: string, predicate: (fileName: string) => boolea
830
844
  return files;
831
845
  }
832
846
 
847
+ function isLegacyAgentSkillPath(rootDir: string, filePath: string): boolean {
848
+ const relative = path.relative(rootDir, filePath);
849
+ const parts = relative.split(path.sep).map((part) => part.toLowerCase());
850
+ if (path.basename(rootDir).toLowerCase() === ".agents") {
851
+ parts.unshift(".agents");
852
+ }
853
+ return parts.some((part, index) => part === ".agents" && parts[index + 1] === "skills");
854
+ }
855
+
833
856
  function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
834
857
  const agents: AgentConfig[] = [];
835
858
 
836
859
  for (const filePath of listFilesRecursive(dir, (fileName) => fileName.endsWith(".md") && !fileName.endsWith(".chain.md"))) {
860
+ if (isLegacyAgentSkillPath(dir, filePath)) {
861
+ continue;
862
+ }
863
+
837
864
  let content: string;
838
865
  try {
839
866
  content = fs.readFileSync(filePath, "utf-8");
@@ -912,6 +939,13 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
912
939
  .map((e) => e.trim())
913
940
  .filter(Boolean);
914
941
  }
942
+ let subagentOnlyExtensions: string[] | undefined;
943
+ if (frontmatter.subagentOnlyExtensions !== undefined) {
944
+ subagentOnlyExtensions = frontmatter.subagentOnlyExtensions
945
+ .split(",")
946
+ .map((e) => e.trim())
947
+ .filter(Boolean);
948
+ }
915
949
 
916
950
  const extraFields: Record<string, string> = {};
917
951
  for (const [key, value] of Object.entries(frontmatter)) {
@@ -944,6 +978,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
944
978
  filePath,
945
979
  skills: skills && skills.length > 0 ? skills : undefined,
946
980
  extensions,
981
+ subagentOnlyExtensions,
947
982
  output: frontmatter.output,
948
983
  defaultReads: defaultReads && defaultReads.length > 0 ? defaultReads : undefined,
949
984
  defaultProgress: frontmatter.defaultProgress === "true",
@@ -0,0 +1,191 @@
1
+ import type { AgentConfig, ChainConfig, ChainStepConfig } from "./agents.ts";
2
+ import type { ProactiveSkillSubagentsConfig } from "../shared/types.ts";
3
+
4
+ const SUBAGENT_ORCHESTRATION_SKILL = "pi-subagents";
5
+ const DEFAULT_MIN_REFERENCES = 2;
6
+ const DEFAULT_MAX_RECOMMENDATIONS = 3;
7
+ const DEFAULT_PREFERRED_AGENT = "reviewer";
8
+ const FALLBACK_AGENT_ORDER = ["reviewer", "context-builder", "delegate"];
9
+ const MAX_RECOMMENDATION_CAP = 5;
10
+
11
+ export interface ResolvedProactiveSkillSubagentsConfig {
12
+ enabled: boolean;
13
+ minReferences: number;
14
+ maxRecommendations: number;
15
+ preferredAgent: string;
16
+ }
17
+
18
+ export interface ProactiveSkillSubagentRecommendation {
19
+ skill: string;
20
+ agent: string;
21
+ references: number;
22
+ sources: string[];
23
+ description?: string;
24
+ reason: string;
25
+ }
26
+
27
+ export interface AvailableSkill {
28
+ name: string;
29
+ description?: string;
30
+ }
31
+
32
+ function positiveInteger(value: unknown): number | undefined {
33
+ if (typeof value !== "number") return undefined;
34
+ if (!Number.isInteger(value) || !Number.isFinite(value) || value < 1) return undefined;
35
+ return value;
36
+ }
37
+
38
+ export function resolveProactiveSkillSubagentsConfig(
39
+ config?: ProactiveSkillSubagentsConfig | false,
40
+ ): ResolvedProactiveSkillSubagentsConfig {
41
+ if (config === false) {
42
+ return {
43
+ enabled: false,
44
+ minReferences: DEFAULT_MIN_REFERENCES,
45
+ maxRecommendations: DEFAULT_MAX_RECOMMENDATIONS,
46
+ preferredAgent: DEFAULT_PREFERRED_AGENT,
47
+ };
48
+ }
49
+
50
+ const maxRecommendations = positiveInteger(config?.maxRecommendations) ?? DEFAULT_MAX_RECOMMENDATIONS;
51
+ return {
52
+ enabled: config?.enabled ?? true,
53
+ minReferences: positiveInteger(config?.minReferences) ?? DEFAULT_MIN_REFERENCES,
54
+ maxRecommendations: Math.min(maxRecommendations, MAX_RECOMMENDATION_CAP),
55
+ preferredAgent: typeof config?.preferredAgent === "string" && config.preferredAgent.trim()
56
+ ? config.preferredAgent.trim()
57
+ : DEFAULT_PREFERRED_AGENT,
58
+ };
59
+ }
60
+
61
+ function normalizeSkillNames(value: unknown): string[] {
62
+ if (value === false || value === true || value === undefined || value === null) return [];
63
+ if (Array.isArray(value)) {
64
+ return [...new Set(value.filter((entry): entry is string => typeof entry === "string").map((entry) => entry.trim()).filter(Boolean))];
65
+ }
66
+ if (typeof value === "string") {
67
+ return [...new Set(value.split(",").map((entry) => entry.trim()).filter(Boolean))];
68
+ }
69
+ return [];
70
+ }
71
+
72
+ function collectStepSkills(step: ChainStepConfig, out: Set<string>): void {
73
+ for (const skill of normalizeSkillNames(step.skills ?? (step as { skill?: unknown }).skill)) {
74
+ out.add(skill);
75
+ }
76
+
77
+ const parallel = step.parallel;
78
+ if (!parallel) return;
79
+ if (Array.isArray(parallel)) {
80
+ for (const child of parallel) {
81
+ if (child && typeof child === "object" && !Array.isArray(child)) {
82
+ collectStepSkills(child as ChainStepConfig, out);
83
+ }
84
+ }
85
+ return;
86
+ }
87
+ if (typeof parallel === "object") {
88
+ collectStepSkills(parallel as ChainStepConfig, out);
89
+ }
90
+ }
91
+
92
+ function chooseRecommendationAgent(agents: AgentConfig[], preferredAgent: string): string | undefined {
93
+ const enabled = agents.filter((agent) => !agent.disabled);
94
+ if (enabled.some((agent) => agent.name === preferredAgent)) return preferredAgent;
95
+ for (const name of FALLBACK_AGENT_ORDER) {
96
+ if (enabled.some((agent) => agent.name === name)) return name;
97
+ }
98
+ return enabled[0]?.name;
99
+ }
100
+
101
+ function addSource(counts: Map<string, Set<string>>, skill: string, source: string): void {
102
+ if (skill === SUBAGENT_ORCHESTRATION_SKILL) return;
103
+ const sources = counts.get(skill) ?? new Set<string>();
104
+ sources.add(source);
105
+ counts.set(skill, sources);
106
+ }
107
+
108
+ export function recommendProactiveSkillSubagents(input: {
109
+ agents: AgentConfig[];
110
+ chains?: ChainConfig[];
111
+ availableSkills?: AvailableSkill[];
112
+ config?: ProactiveSkillSubagentsConfig | false;
113
+ }): ProactiveSkillSubagentRecommendation[] {
114
+ const config = resolveProactiveSkillSubagentsConfig(input.config);
115
+ if (!config.enabled) return [];
116
+
117
+ const agent = chooseRecommendationAgent(input.agents, config.preferredAgent);
118
+ if (!agent) return [];
119
+
120
+ const availableByName = input.availableSkills
121
+ ? new Map(input.availableSkills.map((skill) => [skill.name, skill]))
122
+ : undefined;
123
+ const counts = new Map<string, Set<string>>();
124
+
125
+ for (const candidate of input.agents) {
126
+ if (candidate.disabled) continue;
127
+ for (const skill of candidate.skills ?? []) {
128
+ addSource(counts, skill, `agent:${candidate.name}`);
129
+ }
130
+ }
131
+
132
+ for (const chain of input.chains ?? []) {
133
+ const chainSkills = new Set<string>();
134
+ for (const step of chain.steps) {
135
+ collectStepSkills(step, chainSkills);
136
+ }
137
+ for (const skill of chainSkills) {
138
+ addSource(counts, skill, `chain:${chain.name}`);
139
+ }
140
+ }
141
+
142
+ return [...counts.entries()]
143
+ .filter(([skill, sources]) => sources.size >= config.minReferences && (!availableByName || availableByName.has(skill)))
144
+ .map(([skill, sources]) => ({
145
+ skill,
146
+ agent,
147
+ references: sources.size,
148
+ sources: [...sources].sort((a, b) => a.localeCompare(b)),
149
+ description: availableByName?.get(skill)?.description,
150
+ reason: `referenced by ${sources.size} configured agents/chains`,
151
+ }))
152
+ .sort((a, b) => b.references - a.references || a.skill.localeCompare(b.skill))
153
+ .slice(0, config.maxRecommendations);
154
+ }
155
+
156
+ export function formatProactiveSkillSubagentRecommendations(
157
+ recommendations: ProactiveSkillSubagentRecommendation[],
158
+ ): string[] {
159
+ if (recommendations.length === 0) return [];
160
+ return [
161
+ "Proactive skill subagent suggestions:",
162
+ ...recommendations.map((recommendation) => {
163
+ const sampleSources = recommendation.sources.slice(0, 3).join(", ");
164
+ const extra = recommendation.sources.length > 3 ? `, +${recommendation.sources.length - 3} more` : "";
165
+ const description = recommendation.description ? ` - ${recommendation.description}` : "";
166
+ return `- ${recommendation.skill} via ${recommendation.agent} (${recommendation.reason}; ${sampleSources}${extra})${description}`;
167
+ }),
168
+ "Guardrails: use these for broad tasks where a skill-specialist pass is useful; keep fanout small, use fresh context unless private/session context is explicitly needed, and skip when the user asks for a direct answer.",
169
+ ];
170
+ }
171
+
172
+ export function buildProactiveSkillSubagentRecommendationLines(input: {
173
+ agents: AgentConfig[];
174
+ chains?: ChainConfig[];
175
+ config?: ProactiveSkillSubagentsConfig | false;
176
+ discoverAvailableSkills: () => AvailableSkill[];
177
+ }): string[] {
178
+ if (!resolveProactiveSkillSubagentsConfig(input.config).enabled) return [];
179
+ let availableSkills: AvailableSkill[];
180
+ try {
181
+ availableSkills = input.discoverAvailableSkills();
182
+ } catch {
183
+ availableSkills = [];
184
+ }
185
+ return formatProactiveSkillSubagentRecommendations(recommendProactiveSkillSubagents({
186
+ agents: input.agents,
187
+ chains: input.chains,
188
+ availableSkills,
189
+ config: input.config,
190
+ }));
191
+ }
@@ -156,7 +156,7 @@ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI):
156
156
  label: "Subagent",
157
157
  description: [
158
158
  "Delegate to subagents from child-safe fanout mode.",
159
- "Allowed management/control actions: list, get, status, interrupt, resume, doctor.",
159
+ "Allowed management/control actions: list, get, status, interrupt, resume, append-step, doctor.",
160
160
  "Agent config mutation actions create, update, and delete are blocked in this mode.",
161
161
  ].join("\n"),
162
162
  parameters: SubagentParams,
@@ -393,6 +393,7 @@ EXECUTION (use exactly ONE mode):
393
393
  • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
394
394
  • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
395
395
  • Optional context: { context: "fresh" | "fork" } (default: if any requested agent has defaultContext: "fork", the whole invocation uses fork; otherwise "fresh"; inspect agent defaults via { action: "list" })
396
+ • If { action: "list" } shows proactive skill subagent suggestions, consider a small fresh-context fanout for broad tasks where one of those skills would materially help
396
397
 
397
398
  CHAIN TEMPLATE VARIABLES (use in task strings):
398
399
  • {task} - The original task/request from the user
@@ -412,7 +413,8 @@ MANAGEMENT (use action field, omit agent/task/chain/tasks):
412
413
  CONTROL:
413
414
  • { action: "status", id: "..." } - inspect an async/background run by id or prefix
414
415
  • { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused
415
- • { action: "resume", id: "...", message: "...", index?: 0 } - follow up with a live async child or revive a completed async/foreground child from its session
416
+ • { action: "resume", id: "...", message: "...", index?: 0 } - interrupt then follow up with a live async child, or revive a completed async/foreground child from its session
417
+ • { action: "append-step", id: "...", chain: [{agent:"agent-c", task:"Use {previous}"}] } - append one step to the tail of a running async chain
416
418
 
417
419
  DIAGNOSTICS:
418
420
  • { action: "doctor" } - read-only report for runtime paths, discovery, sessions, and intercom`,
@@ -5,6 +5,31 @@
5
5
  import { Type } from "typebox";
6
6
  import { SUBAGENT_ACTIONS } from "../shared/types.ts";
7
7
 
8
+ function keepTopLevelParameterDescriptions<T>(schema: T): T {
9
+ return pruneNestedDescriptions(schema, []) as T;
10
+ }
11
+
12
+ function pruneNestedDescriptions(value: unknown, path: string[]): unknown {
13
+ if (!value || typeof value !== "object") return value;
14
+
15
+ const result = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
16
+ for (const key of Reflect.ownKeys(value)) {
17
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
18
+ if (!descriptor) continue;
19
+ if (key === "description" && !isTopLevelParameterDescription(path)) continue;
20
+ if ("value" in descriptor) {
21
+ const nextPath = typeof key === "string" ? [...path, key] : path;
22
+ descriptor.value = pruneNestedDescriptions(descriptor.value, nextPath);
23
+ }
24
+ Object.defineProperty(result, key, descriptor);
25
+ }
26
+ return result;
27
+ }
28
+
29
+ function isTopLevelParameterDescription(path: string[]): boolean {
30
+ return path.length === 2 && path[0] === "properties";
31
+ }
32
+
8
33
  const SkillOverride = Type.Unsafe({
9
34
  anyOf: [
10
35
  { type: "array", items: { type: "string" } },
@@ -233,7 +258,7 @@ const ControlOverrides = Type.Object({
233
258
  })),
234
259
  });
235
260
 
236
- export const SubagentParams = Type.Object({
261
+ const SubagentParamsSchema = Type.Object({
237
262
  agent: Type.Optional(Type.String({ description: "Agent name (SINGLE mode) or target for management get/update/delete" })),
238
263
  task: Type.Optional(Type.String({ description: "Task (SINGLE mode, optional for self-contained agents)" })),
239
264
  // Management action (when present, tool operates in management mode)
@@ -242,10 +267,10 @@ export const SubagentParams = Type.Object({
242
267
  description: "Management/control action. Omit for execution mode."
243
268
  })),
244
269
  id: Type.Optional(Type.String({
245
- description: "Run id or prefix for action='status', action='interrupt', or action='resume'."
270
+ description: "Run id or prefix for action='status', action='interrupt', action='resume', or action='append-step'."
246
271
  })),
247
272
  runId: Type.Optional(Type.String({
248
- description: "Target run ID for action='interrupt' or action='resume'. Defaults to the most recently active controllable run for interrupt. Prefer id for new calls."
273
+ description: "Target run ID for action='interrupt', action='resume', or action='append-step'. Defaults to the most recently active controllable run for interrupt. Prefer id for new calls."
249
274
  })),
250
275
  dir: Type.Optional(Type.String({
251
276
  description: "Async run directory for action='status' or action='resume'."
@@ -262,7 +287,7 @@ export const SubagentParams = Type.Object({
262
287
  { type: "object", additionalProperties: true },
263
288
  { type: "string" },
264
289
  ],
265
- description: "Agent or chain config for create/update. Agent: name, package (optional namespace; runtime name becomes package.name), description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, package, description, scope, steps (array of {agent, task?, output?, outputMode?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
290
+ description: "Agent or chain config for create/update. Agent: name, package (optional namespace; runtime name becomes package.name), description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), subagentOnlyExtensions (comma-separated child-only extension paths), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, package, description, scope, steps (array of {agent, task?, output?, outputMode?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
266
291
  })),
267
292
  tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?, output?, outputMode?, reads?, progress?}, ...]" })),
268
293
  concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Top-level PARALLEL mode only: max concurrent tasks. Defaults to config.parallel.concurrency or 4." })),
@@ -271,7 +296,7 @@ export const SubagentParams = Type.Object({
271
296
  "Prevents filesystem conflicts. Requires clean git state. " +
272
297
  "Per-worktree diffs included in output."
273
298
  })),
274
- chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential pipeline where each step's response becomes {previous} for the next. Use {task}, {previous}, {chain_dir} in task templates." })),
299
+ chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential pipeline where each step's response becomes {previous} for the next. With action='append-step', provide exactly one step to append to an active async chain; it can use {previous}, {chain_dir}, and existing {outputs.name} references." })),
275
300
  context: Type.Optional(Type.String({
276
301
  enum: ["fresh", "fork"],
277
302
  description: "'fresh' or 'fork' to branch from parent session. If omitted, any requested agent with defaultContext: 'fork' makes the whole invocation forked; otherwise the default is 'fresh'.",
@@ -302,3 +327,5 @@ export const SubagentParams = Type.Object({
302
327
  model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
303
328
  acceptance: Type.Optional(AcceptanceOverride),
304
329
  });
330
+
331
+ export const SubagentParams = keepTopLevelParameterDescriptions(SubagentParamsSchema);