pi-subagents 0.30.0 → 0.31.1

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +189 -18
  3. package/agents/context-builder.md +3 -3
  4. package/agents/planner.md +1 -1
  5. package/agents/researcher.md +1 -1
  6. package/agents/scout.md +1 -1
  7. package/package.json +7 -7
  8. package/skills/pi-subagents/SKILL.md +5 -0
  9. package/src/agents/agent-management.ts +170 -6
  10. package/src/agents/agent-serializer.ts +31 -13
  11. package/src/agents/agents.ts +207 -23
  12. package/src/agents/frontmatter.ts +66 -2
  13. package/src/agents/skills.ts +117 -20
  14. package/src/extension/doctor.ts +20 -0
  15. package/src/extension/fanout-child.ts +1 -0
  16. package/src/extension/index.ts +58 -4
  17. package/src/extension/schemas.ts +10 -76
  18. package/src/intercom/intercom-bridge.ts +27 -4
  19. package/src/profiles/profiles.ts +637 -0
  20. package/src/runs/background/async-execution.ts +14 -4
  21. package/src/runs/background/async-job-tracker.ts +56 -11
  22. package/src/runs/background/async-resume.ts +11 -13
  23. package/src/runs/background/control-channel.ts +177 -0
  24. package/src/runs/background/result-watcher.ts +11 -2
  25. package/src/runs/background/stale-run-reconciler.ts +9 -4
  26. package/src/runs/background/subagent-runner.ts +86 -3
  27. package/src/runs/foreground/chain-execution.ts +26 -2
  28. package/src/runs/foreground/execution.ts +113 -8
  29. package/src/runs/foreground/subagent-executor.ts +356 -86
  30. package/src/runs/shared/acceptance.ts +285 -34
  31. package/src/runs/shared/completion-guard.ts +1 -1
  32. package/src/runs/shared/dynamic-fanout.ts +4 -2
  33. package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -2
  34. package/src/runs/shared/parallel-utils.ts +6 -1
  35. package/src/runs/shared/pi-args.ts +9 -1
  36. package/src/runs/shared/single-output.ts +15 -1
  37. package/src/runs/shared/subagent-prompt-runtime.ts +1 -0
  38. package/src/shared/settings.ts +1 -0
  39. package/src/shared/types.ts +9 -2
  40. package/src/shared/utils.ts +19 -1
  41. package/src/slash/prompt-template-bridge.ts +26 -3
  42. package/src/slash/slash-commands.ts +642 -43
  43. package/src/tui/render.ts +265 -13
@@ -3,10 +3,21 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { Key, matchesKey } from "@earendil-works/pi-tui";
6
- import { discoverAgents, discoverAgentsAll, type ChainConfig } from "../agents/agents.ts";
6
+ import { BUILTIN_AGENT_NAMES, discoverAgents, discoverAgentsAll, type ChainConfig } from "../agents/agents.ts";
7
+ import {
8
+ DEFAULT_PROVIDER_MODELS_MAX_AGE_DAYS,
9
+ applySubagentProfile,
10
+ checkSubagentProfile,
11
+ generateProfilesForProvider,
12
+ listSubagentProfiles,
13
+ readSubagentProfile,
14
+ refreshProviderModelCatalog,
15
+ } from "../profiles/profiles.ts";
7
16
  import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
8
17
  import { isDynamicParallelStep, isParallelStep, type ChainStep } from "../shared/settings.ts";
18
+ import { findModelInfo, toModelInfo } from "../shared/model-info.ts";
9
19
  import { assertJsonSchemaObject } from "../runs/shared/structured-output.ts";
20
+ import { validateAcceptanceInput } from "../runs/shared/acceptance.ts";
10
21
  import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
11
22
  import {
12
23
  applySlashUpdate,
@@ -16,6 +27,7 @@ import {
16
27
  } from "./slash-live-state.ts";
17
28
  import {
18
29
  SLASH_RESULT_TYPE,
30
+ SLASH_TEXT_RESULT_TYPE,
19
31
  SLASH_SUBAGENT_CANCEL_EVENT,
20
32
  SLASH_SUBAGENT_REQUEST_EVENT,
21
33
  SLASH_SUBAGENT_RESPONSE_EVENT,
@@ -33,6 +45,13 @@ interface InlineConfig {
33
45
  model?: string;
34
46
  skill?: string[] | false;
35
47
  progress?: boolean;
48
+ as?: string;
49
+ label?: string;
50
+ phase?: string;
51
+ cwd?: string;
52
+ count?: number;
53
+ outputSchema?: string;
54
+ acceptance?: string;
36
55
  }
37
56
 
38
57
  const parseInlineConfig = (raw: string): InlineConfig => {
@@ -54,6 +73,13 @@ const parseInlineConfig = (raw: string): InlineConfig => {
54
73
  case "model": config.model = val || undefined; break;
55
74
  case "skill": case "skills": config.skill = val === "false" ? false : val.split("+").filter(Boolean); break;
56
75
  case "progress": config.progress = val !== "false"; break;
76
+ case "as": config.as = val || undefined; break;
77
+ case "label": config.label = val || undefined; break;
78
+ case "phase": config.phase = val || undefined; break;
79
+ case "cwd": config.cwd = val || undefined; break;
80
+ case "count": { const n = Number(val); if (Number.isInteger(n) && n > 0) config.count = n; break; }
81
+ case "outputSchema": config.outputSchema = val || undefined; break;
82
+ case "acceptance": config.acceptance = val || undefined; break;
57
83
  }
58
84
  }
59
85
  return config;
@@ -96,16 +122,44 @@ const makeAgentCompletions = (state: SubagentState, multiAgent: boolean) => (pre
96
122
  return agents.filter((a) => a.name.startsWith(prefix)).map((a) => ({ value: a.name, label: a.name }));
97
123
  }
98
124
 
99
- const lastArrow = prefix.lastIndexOf(" -> ");
100
- const segment = lastArrow !== -1 ? prefix.slice(lastArrow + 4) : prefix;
125
+ // Find the start of the current chain step: after the last top-level `->` arrow or `(`,
126
+ // or after a `|` *inside* a group. A `|` at depth 0 is plain task text (only `(` opens a
127
+ // group), so it must not restart agent completion — otherwise `scout -- do x | wr` would
128
+ // wrongly resume suggesting agents past the `--` task. Quotes are tracked so separators
129
+ // inside a task are ignored.
130
+ let inSingle = false, inDouble = false, depth = 0, segStart = 0;
131
+ for (let i = 0; i < prefix.length; i++) {
132
+ const ch = prefix[i]!;
133
+ if (inSingle) { if (ch === "'") inSingle = false; continue; }
134
+ if (inDouble) { if (ch === '"') inDouble = false; continue; }
135
+ if (ch === "'") { inSingle = true; continue; }
136
+ if (ch === '"') { inDouble = true; continue; }
137
+ if (ch === "(") {
138
+ if (!prefix.slice(segStart, i).includes(" -- ")) {
139
+ depth++;
140
+ segStart = i + 1;
141
+ }
142
+ }
143
+ else if (ch === ")") {
144
+ if (depth > 0) {
145
+ depth--;
146
+ segStart = i + 1;
147
+ }
148
+ }
149
+ else if (ch === "|" && depth > 0) segStart = i + 1;
150
+ else if (ch === ">" && prefix[i - 1] === "-" && depth === 0) segStart = i + 1;
151
+ }
152
+ // Inside an open quote, or once the task has started (`--` / a quote), we are no
153
+ // longer typing an agent name.
154
+ if (inSingle || inDouble) return null;
155
+ const segment = prefix.slice(segStart);
101
156
  if (segment.includes(" -- ") || segment.includes('"') || segment.includes("'")) return null;
102
157
 
103
- const lastWord = (prefix.match(/(\S*)$/) || ["", ""])[1];
104
- const beforeLastWord = prefix.slice(0, prefix.length - lastWord.length);
105
-
106
- if (lastWord === "->") {
107
- return agents.map((a) => ({ value: `${prefix} ${a.name}`, label: a.name }));
108
- }
158
+ const lastWord = (segment.match(/(\S*)$/) || ["", ""])[1];
159
+ let beforeLastWord = prefix.slice(0, prefix.length - lastWord.length);
160
+ // A bare `->` or `|` just typed (no trailing space) needs a separating space;
161
+ // `(` glues naturally to the agent name.
162
+ if (lastWord === "" && /[>|]$/.test(beforeLastWord)) beforeLastWord = `${beforeLastWord} `;
109
163
 
110
164
  return agents.filter((a) => a.name.startsWith(lastWord)).map((a) => ({ value: `${beforeLastWord}${a.name}`, label: a.name }));
111
165
  };
@@ -125,6 +179,54 @@ const makeChainCompletions = (state: SubagentState) => (prefix: string) => {
125
179
  .map((chain) => ({ value: chain.name, label: chain.name }));
126
180
  };
127
181
 
182
+ const makeBuiltinAgentNameCompletions = () => (prefix: string) => {
183
+ if (prefix.includes(" ")) return null;
184
+ return BUILTIN_AGENT_NAMES
185
+ .filter((name) => name.startsWith(prefix))
186
+ .map((name) => ({ value: name, label: name }));
187
+ };
188
+
189
+ const makeProviderCompletions = (state: SubagentState) => (prefix: string) => {
190
+ if (prefix.includes(" ")) return null;
191
+ const available = state.lastUiContext?.modelRegistry?.getAvailable?.();
192
+ if (!Array.isArray(available)) return null;
193
+ const providers = [...new Set(available
194
+ .map((model) => typeof model?.provider === "string" ? model.provider : "")
195
+ .filter(Boolean))]
196
+ .sort((a, b) => a.localeCompare(b));
197
+ return providers
198
+ .filter((provider) => provider.startsWith(prefix))
199
+ .map((provider) => ({ value: provider, label: provider }));
200
+ };
201
+
202
+ function sendSlashText(pi: ExtensionAPI, text: string): void {
203
+ pi.sendMessage({ customType: SLASH_TEXT_RESULT_TYPE, content: text, display: true });
204
+ }
205
+
206
+ async function withSlashStatus<T>(
207
+ ctx: ExtensionContext,
208
+ text: string,
209
+ run: () => Promise<T>,
210
+ ): Promise<T> {
211
+ if (ctx.hasUI) ctx.ui.setStatus("subagent-slash-text", text);
212
+ try {
213
+ return await run();
214
+ } finally {
215
+ if (ctx.hasUI) ctx.ui.setStatus("subagent-slash-text", undefined);
216
+ }
217
+ }
218
+
219
+ function parseSingleRequiredArg(args: string, usage: string): { ok: true; value: string } | { ok: false; message: string } {
220
+ const parts = args.trim().split(/\s+/).filter(Boolean);
221
+ if (parts.length !== 1) return { ok: false, message: usage };
222
+ return { ok: true, value: parts[0]! };
223
+ }
224
+
225
+ function getProfileWorkerModel(profile: { subagents?: { agentOverrides?: Record<string, { model?: string }> } }): string | undefined {
226
+ const model = profile.subagents?.agentOverrides?.worker?.model;
227
+ return typeof model === "string" && model.trim() ? model.trim() : undefined;
228
+ }
229
+
128
230
  function loadSavedOutputSchema(chain: ChainConfig, stepAgent: string, outputSchema: unknown): JsonSchemaObject | undefined {
129
231
  if (outputSchema === undefined) return undefined;
130
232
  if (typeof outputSchema === "string") {
@@ -167,6 +269,7 @@ const mapSavedChainSteps = (chain: ChainConfig, worktree = false): ChainStep[] =
167
269
  ...(step.label ? { label: step.label } : {}),
168
270
  ...(step.as ? { as: step.as } : {}),
169
271
  ...(outputSchema ? { outputSchema } : {}),
272
+ ...((step as { acceptance?: unknown }).acceptance !== undefined ? { acceptance: (step as { acceptance?: unknown }).acceptance } : {}),
170
273
  output: step.output,
171
274
  outputMode: step.outputMode,
172
275
  reads: step.reads,
@@ -330,7 +433,7 @@ async function runSlashSubagent(
330
433
  pi.sendMessage({
331
434
  customType: SLASH_RESULT_TYPE,
332
435
  content: buildSlashExportText(response),
333
- display: true,
436
+ display: !ctx.hasUI,
334
437
  details: finalDetails,
335
438
  });
336
439
  persistSlashSessionSnapshot(ctx);
@@ -346,7 +449,7 @@ async function runSlashSubagent(
346
449
  pi.sendMessage({
347
450
  customType: SLASH_RESULT_TYPE,
348
451
  content: `## Subagent result\n\n${message}`,
349
- display: true,
452
+ display: !ctx.hasUI,
350
453
  details: failedDetails,
351
454
  });
352
455
  persistSlashSessionSnapshot(ctx);
@@ -362,7 +465,181 @@ async function runSlashSubagent(
362
465
  }
363
466
 
364
467
 
365
- interface ParsedStep { name: string; config: InlineConfig; task?: string }
468
+ export interface GroupConfig { concurrency?: number; failFast?: boolean; worktree?: boolean }
469
+ export interface ParsedStep { kind: "step"; name: string; config: InlineConfig; task?: string }
470
+ export interface ParsedGroup { kind: "group"; tasks: ParsedStep[]; config: GroupConfig }
471
+ export type ParsedGroupStep = ParsedStep | ParsedGroup;
472
+
473
+ export const PARALLEL_GROUP_USAGE =
474
+ 'Usage: /chain agent "task" -> (agent2 "task" | agent3 "task") -> agent4';
475
+
476
+ export class SlashParseError extends Error {}
477
+
478
+ // Walk `input` tracking quote/paren state; returns true if parens are unbalanced.
479
+ function findUnmatchedCloseParen(input: string): boolean {
480
+ let depth = 0, inSingle = false, inDouble = false;
481
+ for (let i = 0; i < input.length; i++) {
482
+ const ch = input[i]!;
483
+ if (inSingle) { if (ch === "'") inSingle = false; continue; }
484
+ if (inDouble) { if (ch === '"') inDouble = false; continue; }
485
+ if (ch === "'") { inSingle = true; continue; }
486
+ if (ch === '"') { inDouble = true; continue; }
487
+ if (ch === "(") depth++;
488
+ else if (ch === ")") { depth--; if (depth < 0) return true; }
489
+ }
490
+ return depth !== 0;
491
+ }
492
+
493
+ // Split on top-level " -> ", ignoring arrows inside quotes or parentheses.
494
+ function splitOnArrow(input: string): string[] {
495
+ const segments: string[] = [];
496
+ let depth = 0, inSingle = false, inDouble = false, start = 0;
497
+ for (let i = 0; i < input.length; i++) {
498
+ const ch = input[i]!;
499
+ if (inSingle) { if (ch === "'") inSingle = false; continue; }
500
+ if (inDouble) { if (ch === '"') inDouble = false; continue; }
501
+ if (ch === "'") { inSingle = true; continue; }
502
+ if (ch === '"') { inDouble = true; continue; }
503
+ if (ch === "(") depth++;
504
+ else if (ch === ")") depth--;
505
+ else if (depth === 0 && ch === "-" && input[i + 1] === ">" && input[i + 2] === " ") {
506
+ segments.push(input.slice(start, i));
507
+ i += 2;
508
+ start = i + 1;
509
+ }
510
+ }
511
+ segments.push(input.slice(start));
512
+ return segments;
513
+ }
514
+
515
+ // Split a group's inner text on top-level " | ", ignoring pipes inside quotes/parens.
516
+ function splitGroupTasks(inner: string): string[] {
517
+ const parts: string[] = [];
518
+ let depth = 0, inSingle = false, inDouble = false, start = 0;
519
+ for (let i = 0; i < inner.length; i++) {
520
+ const ch = inner[i]!;
521
+ if (inSingle) { if (ch === "'") inSingle = false; continue; }
522
+ if (inDouble) { if (ch === '"') inDouble = false; continue; }
523
+ if (ch === "'") { inSingle = true; continue; }
524
+ if (ch === '"') { inDouble = true; continue; }
525
+ if (ch === "(") depth++;
526
+ else if (ch === ")") depth--;
527
+ else if (ch === "|" && depth === 0) {
528
+ parts.push(inner.slice(start, i));
529
+ start = i + 1;
530
+ }
531
+ }
532
+ parts.push(inner.slice(start));
533
+ return parts;
534
+ }
535
+
536
+ export function parseSingleTaskToken(token: string): ParsedStep {
537
+ let agentPart: string;
538
+ let task: string | undefined;
539
+ const qMatch = token.match(/^(\S+(?:\[[^\]]*\])?)\s+(?:"([^"]*)"|'([^']*)')$/);
540
+ if (qMatch) {
541
+ agentPart = qMatch[1]!;
542
+ task = (qMatch[2] ?? qMatch[3]) || undefined;
543
+ } else {
544
+ const dashIdx = token.indexOf(" -- ");
545
+ if (dashIdx !== -1) {
546
+ agentPart = token.slice(0, dashIdx).trim();
547
+ task = token.slice(dashIdx + 4).trim() || undefined;
548
+ } else {
549
+ agentPart = token;
550
+ }
551
+ }
552
+ return { kind: "step", ...parseAgentToken(agentPart), task };
553
+ }
554
+
555
+ const parseGroupConfig = (raw: string): GroupConfig => {
556
+ const config: GroupConfig = {};
557
+ for (const part of raw.split(",")) {
558
+ const trimmed = part.trim();
559
+ if (!trimmed) continue;
560
+ const eq = trimmed.indexOf("=");
561
+ const key = eq === -1 ? trimmed : trimmed.slice(0, eq).trim();
562
+ const val = eq === -1 ? "" : trimmed.slice(eq + 1).trim();
563
+ switch (key) {
564
+ case "concurrency": { const n = Number(val); if (Number.isInteger(n) && n > 0) config.concurrency = n; break; }
565
+ case "failFast": config.failFast = eq === -1 ? true : val !== "false"; break;
566
+ case "worktree": config.worktree = eq === -1 ? true : val !== "false"; break;
567
+ }
568
+ }
569
+ return config;
570
+ };
571
+
572
+ // Split `(...)` from an optional trailing `[...]` group-config suffix, respecting
573
+ // quotes and nested parens. Returns the inner group text and the parsed config.
574
+ const splitGroupBody = (trimmed: string): { inner: string; config: GroupConfig } => {
575
+ let depth = 0, inSingle = false, inDouble = false, closeIdx = -1;
576
+ for (let i = 0; i < trimmed.length; i++) {
577
+ const ch = trimmed[i]!;
578
+ if (inSingle) { if (ch === "'") inSingle = false; continue; }
579
+ if (inDouble) { if (ch === '"') inDouble = false; continue; }
580
+ if (ch === "'") { inSingle = true; continue; }
581
+ if (ch === '"') { inDouble = true; continue; }
582
+ if (ch === "(") depth++;
583
+ else if (ch === ")") { depth--; if (depth === 0) { closeIdx = i; break; } }
584
+ }
585
+ if (closeIdx === -1) throw new SlashParseError(`Unmatched parentheses in group: '${trimmed}'`);
586
+ const inner = trimmed.slice(1, closeIdx);
587
+ const suffix = trimmed.slice(closeIdx + 1).trim();
588
+ if (!suffix) return { inner, config: {} };
589
+ if (!suffix.startsWith("[") || !suffix.endsWith("]")) {
590
+ throw new SlashParseError(`Group options must be wrapped in [...]: '${suffix}'`);
591
+ }
592
+ return { inner, config: parseGroupConfig(suffix.slice(1, -1)) };
593
+ };
594
+
595
+ export function parseGroupSegment(segment: string): ParsedGroup {
596
+ const trimmed = segment.trim();
597
+ if (!trimmed.startsWith("(")) {
598
+ throw new SlashParseError(`Parallel group must be wrapped in parentheses: '${trimmed}'`);
599
+ }
600
+ const { inner, config } = splitGroupBody(trimmed);
601
+ const rawParts = splitGroupTasks(inner).map((p) => p.trim()).filter((p) => p.length > 0);
602
+ if (rawParts.length < 2) {
603
+ throw new SlashParseError("Parallel group must contain at least two tasks separated by ' | '");
604
+ }
605
+ return { kind: "group", tasks: rawParts.map((part) => parseSingleTaskToken(part)), config };
606
+ }
607
+
608
+ // True if `input` uses inline parallel-group syntax. A group is a *step* that begins
609
+ // with `(` at the top level, so we split on top-level ` -> ` arrows and look for a
610
+ // segment that opens with `(`. Parentheses appearing inside a task (e.g.
611
+ // `scout -- inspect auth (backend)`) do not count, which keeps legacy shared-task
612
+ // `-- task` parsing — including a bare `|` inside the task — on the single-agent path.
613
+ export function hasGroupSyntax(input: string): boolean {
614
+ return splitOnArrow(input).some((seg) => seg.trim().startsWith("("));
615
+ }
616
+
617
+ export function parseChainExpression(input: string): { steps: ParsedGroupStep[] } {
618
+ const trimmed = input.trim();
619
+ if (!trimmed.includes(" -> ")) {
620
+ throw new SlashParseError('Parallel groups in /chain require " -> " between steps');
621
+ }
622
+ if (findUnmatchedCloseParen(trimmed)) {
623
+ throw new SlashParseError("Unmatched parentheses in /chain expression");
624
+ }
625
+ const steps: ParsedGroupStep[] = [];
626
+ for (const seg of splitOnArrow(trimmed)) {
627
+ const t = seg.trim();
628
+ if (!t) continue;
629
+ if (t.startsWith("(")) {
630
+ steps.push(parseGroupSegment(t));
631
+ continue;
632
+ }
633
+ if (findUnmatchedCloseParen(t)) {
634
+ throw new SlashParseError(`Unmatched parentheses in chain segment: '${t}'`);
635
+ }
636
+ steps.push(parseSingleTaskToken(t));
637
+ }
638
+ if (steps.length === 0) {
639
+ throw new SlashParseError("/chain expression must include at least one step");
640
+ }
641
+ return { steps };
642
+ }
366
643
 
367
644
  const parseAgentArgs = (
368
645
  state: SubagentState,
@@ -383,23 +660,7 @@ const parseAgentArgs = (
383
660
  for (const seg of segments) {
384
661
  const trimmed = seg.trim();
385
662
  if (!trimmed) continue;
386
- let agentPart: string;
387
- let task: string | undefined;
388
- const qMatch = trimmed.match(/^(\S+(?:\[[^\]]*\])?)\s+(?:"([^"]*)"|'([^']*)')$/);
389
- if (qMatch) {
390
- agentPart = qMatch[1]!;
391
- task = (qMatch[2] ?? qMatch[3]) || undefined;
392
- } else {
393
- const dashIdx = trimmed.indexOf(" -- ");
394
- if (dashIdx !== -1) {
395
- agentPart = trimmed.slice(0, dashIdx).trim();
396
- task = trimmed.slice(dashIdx + 4).trim() || undefined;
397
- } else {
398
- agentPart = trimmed;
399
- }
400
- }
401
- const parsed = parseAgentToken(agentPart);
402
- steps.push({ ...parsed, task });
663
+ steps.push(parseSingleTaskToken(trimmed));
403
664
  }
404
665
  sharedTask = steps.find((s) => s.task)?.task ?? "";
405
666
  } else {
@@ -414,7 +675,7 @@ const parseAgentArgs = (
414
675
  ctx.ui.notify(usage, "error");
415
676
  return null;
416
677
  }
417
- steps = agentsPart.split(/\s+/).filter(Boolean).map((t) => parseAgentToken(t));
678
+ steps = agentsPart.split(/\s+/).filter(Boolean).map((t) => parseSingleTaskToken(t));
418
679
  }
419
680
 
420
681
  if (steps.length === 0) {
@@ -443,6 +704,162 @@ const parseAgentArgs = (
443
704
  return { steps, task: sharedTask };
444
705
  };
445
706
 
707
+ type ChainStepObject = {
708
+ agent: string;
709
+ task?: string;
710
+ output?: string | false;
711
+ outputMode?: "inline" | "file-only";
712
+ reads?: string[] | false;
713
+ model?: string;
714
+ skill?: string[] | false;
715
+ progress?: boolean;
716
+ as?: string;
717
+ label?: string;
718
+ phase?: string;
719
+ cwd?: string;
720
+ count?: number;
721
+ outputSchema?: JsonSchemaObject;
722
+ acceptance?: string;
723
+ };
724
+
725
+ const INLINE_ACCEPTANCE_LEVELS = new Set(["auto", "attested", "checked"]);
726
+
727
+ function validateInlineAcceptanceInput(value: string, agent: string): void {
728
+ const errors = validateAcceptanceInput(value, `acceptance for step '${agent}'`);
729
+ if (errors.length > 0) throw new SlashParseError(errors[0]!);
730
+ if (!INLINE_ACCEPTANCE_LEVELS.has(value)) {
731
+ throw new SlashParseError(`Inline acceptance for step '${agent}' supports auto, attested, or checked. Use the subagent tool API or a saved .chain.json file for none, verified, or reviewed acceptance contracts.`);
732
+ }
733
+ }
734
+
735
+ // Load an inline `outputSchema=<path>` JSON file, resolved against the session cwd.
736
+ // Throws (SlashParseError / fs / JSON) on a missing or malformed schema.
737
+ function loadInlineOutputSchema(baseCwd: string, agent: string, value: string): JsonSchemaObject {
738
+ const schemaPath = path.isAbsolute(value) ? value : path.join(baseCwd, value);
739
+ const label = `outputSchema for step '${agent}' (${schemaPath})`;
740
+ let parsed: unknown;
741
+ try {
742
+ parsed = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
743
+ } catch (error) {
744
+ throw new SlashParseError(`Cannot read ${label}: ${error instanceof Error ? error.message : String(error)}`);
745
+ }
746
+ assertJsonSchemaObject(parsed, label);
747
+ return parsed;
748
+ }
749
+
750
+ // Build a ChainStep object from a parsed token. `inGroup` enables `count` (parallel-only).
751
+ // May throw SlashParseError for an invalid acceptance level or outputSchema path.
752
+ const mapParsedTaskToStepObject = (
753
+ step: ParsedStep,
754
+ fallbackTask: string | undefined,
755
+ isFirst: boolean,
756
+ opts: { baseCwd: string; inGroup: boolean },
757
+ ): ChainStepObject => {
758
+ const { name, config, task: stepTask } = step;
759
+ if (config.acceptance !== undefined) validateInlineAcceptanceInput(config.acceptance, name);
760
+ return {
761
+ agent: name,
762
+ ...(stepTask ? { task: stepTask } : isFirst && fallbackTask ? { task: fallbackTask } : {}),
763
+ ...(config.output !== undefined ? { output: config.output } : {}),
764
+ ...(config.outputMode !== undefined ? { outputMode: config.outputMode } : {}),
765
+ ...(config.reads !== undefined ? { reads: config.reads } : {}),
766
+ ...(config.model ? { model: config.model } : {}),
767
+ ...(config.skill !== undefined ? { skill: config.skill } : {}),
768
+ ...(config.progress !== undefined ? { progress: config.progress } : {}),
769
+ ...(config.as ? { as: config.as } : {}),
770
+ ...(config.label ? { label: config.label } : {}),
771
+ ...(config.phase ? { phase: config.phase } : {}),
772
+ ...(config.cwd ? { cwd: config.cwd } : {}),
773
+ ...(opts.inGroup && config.count !== undefined ? { count: config.count } : {}),
774
+ ...(config.outputSchema ? { outputSchema: loadInlineOutputSchema(opts.baseCwd, name, config.outputSchema) } : {}),
775
+ ...(config.acceptance ? { acceptance: config.acceptance } : {}),
776
+ };
777
+ };
778
+
779
+ export function buildChainExpressionSteps(
780
+ state: SubagentState,
781
+ input: string,
782
+ ctx: ExtensionContext,
783
+ ): { chain: ChainStep[]; task: string } | null {
784
+ const notify = (message: string) => ctx.ui.notify(message, "error");
785
+ if (!hasGroupSyntax(input)) {
786
+ const parsed = parseAgentArgs(state, input, "chain", ctx);
787
+ if (!parsed) return null;
788
+ const baseCwd = state.baseCwd!; // parseAgentArgs already verified baseCwd is set
789
+ try {
790
+ const chain: ChainStep[] = parsed.steps.map((step, i) =>
791
+ mapParsedTaskToStepObject(step, parsed.task || undefined, i === 0, { baseCwd, inGroup: false }),
792
+ );
793
+ return { chain, task: parsed.task };
794
+ } catch (error) {
795
+ notify(error instanceof Error ? error.message : String(error));
796
+ return null;
797
+ }
798
+ }
799
+
800
+ let expression: { steps: ParsedGroupStep[] };
801
+ try {
802
+ expression = parseChainExpression(input);
803
+ } catch (error) {
804
+ notify(error instanceof Error ? error.message : String(error));
805
+ return null;
806
+ }
807
+ if (!state.baseCwd) {
808
+ notify("Subagent session cwd is not initialized yet");
809
+ return null;
810
+ }
811
+ const agents = discoverAgents(state.baseCwd, "both").agents;
812
+ const stepAgentNames = expression.steps.flatMap((step) =>
813
+ step.kind === "group" ? step.tasks.map((t) => t.name) : [step.name],
814
+ );
815
+ for (const name of stepAgentNames) {
816
+ if (!agents.find((a) => a.name === name)) {
817
+ notify(`Unknown agent: ${name}`);
818
+ return null;
819
+ }
820
+ }
821
+ // Every task inside a parallel group needs its own task; there is no shared-task fallback.
822
+ for (const step of expression.steps) {
823
+ if (step.kind === "group" && step.tasks.some((t) => !t.task)) {
824
+ notify('Each task in a parallel group needs a task: (agent "a" | agent "b")');
825
+ return null;
826
+ }
827
+ }
828
+ const firstStep = expression.steps[0]!;
829
+ const firstHasTask =
830
+ firstStep.kind === "group"
831
+ ? firstStep.tasks.some((t) => Boolean(t.task))
832
+ : Boolean(firstStep.task);
833
+ if (!firstHasTask) {
834
+ notify('First step must have a task: /chain agent "task" -> agent2');
835
+ return null;
836
+ }
837
+ const sharedTask =
838
+ firstStep.kind === "group"
839
+ ? (firstStep.tasks.find((t) => t.task)?.task ?? "")
840
+ : (firstStep.task ?? "");
841
+ const baseCwd = state.baseCwd;
842
+ let chain: ChainStep[];
843
+ try {
844
+ chain = expression.steps.map((step) => {
845
+ if (step.kind === "group") {
846
+ const parallel = step.tasks.map((t) => mapParsedTaskToStepObject(t, undefined, false, { baseCwd, inGroup: true }));
847
+ return {
848
+ parallel,
849
+ ...(step.config.concurrency !== undefined ? { concurrency: step.config.concurrency } : {}),
850
+ ...(step.config.failFast !== undefined ? { failFast: step.config.failFast } : {}),
851
+ ...(step.config.worktree !== undefined ? { worktree: step.config.worktree } : {}),
852
+ };
853
+ }
854
+ return mapParsedTaskToStepObject(step, sharedTask || undefined, false, { baseCwd, inGroup: false });
855
+ });
856
+ } catch (error) {
857
+ notify(error instanceof Error ? error.message : String(error));
858
+ return null;
859
+ }
860
+ return { chain, task: sharedTask };
861
+ }
862
+
446
863
  export function registerSlashCommands(
447
864
  pi: ExtensionAPI,
448
865
  state: SubagentState,
@@ -482,19 +899,9 @@ export function registerSlashCommands(
482
899
  getArgumentCompletions: makeAgentCompletions(state, true),
483
900
  handler: async (args, ctx) => {
484
901
  const { args: cleanedArgs, bg, fork } = extractExecutionFlags(args);
485
- const parsed = parseAgentArgs(state, cleanedArgs, "chain", ctx);
486
- if (!parsed) return;
487
- const chain = parsed.steps.map(({ name, config, task: stepTask }, i) => ({
488
- agent: name,
489
- ...(stepTask ? { task: stepTask } : i === 0 && parsed.task ? { task: parsed.task } : {}),
490
- ...(config.output !== undefined ? { output: config.output } : {}),
491
- ...(config.outputMode !== undefined ? { outputMode: config.outputMode } : {}),
492
- ...(config.reads !== undefined ? { reads: config.reads } : {}),
493
- ...(config.model ? { model: config.model } : {}),
494
- ...(config.skill !== undefined ? { skill: config.skill } : {}),
495
- ...(config.progress !== undefined ? { progress: config.progress } : {}),
496
- }));
497
- const params: SubagentParamsLike = { chain, task: parsed.task, clarify: false, agentScope: "both" };
902
+ const built = buildChainExpressionSteps(state, cleanedArgs, ctx);
903
+ if (!built) return;
904
+ const params: SubagentParamsLike = { chain: built.chain, task: built.task, clarify: false, agentScope: "both" };
498
905
  if (bg) params.async = true;
499
906
  if (fork) params.context = "fork";
500
907
  await runSlashSubagent(pi, ctx, params);
@@ -563,4 +970,196 @@ export function registerSlashCommands(
563
970
  },
564
971
  });
565
972
 
973
+ pi.registerCommand("subagents-models", {
974
+ description: "Show runtime-loaded builtin subagent models",
975
+ getArgumentCompletions: makeBuiltinAgentNameCompletions(),
976
+ handler: async (args, ctx) => {
977
+ const trimmed = args.trim();
978
+ if (!trimmed) {
979
+ await runSlashSubagent(pi, ctx, { action: "models" });
980
+ return;
981
+ }
982
+ const parts = trimmed.split(/\s+/).filter(Boolean);
983
+ if (parts.length !== 1) {
984
+ ctx.ui.notify("Usage: /subagents-models [builtin-agent-name]", "error");
985
+ return;
986
+ }
987
+ const agent = parts[0]!;
988
+ if (!(BUILTIN_AGENT_NAMES as readonly string[]).includes(agent)) {
989
+ ctx.ui.notify(`Unknown builtin agent: ${agent}`, "error");
990
+ return;
991
+ }
992
+ await runSlashSubagent(pi, ctx, { action: "models", agent });
993
+ },
994
+ });
995
+
996
+ pi.registerCommand("subagents-profiles", {
997
+ description: "List saved subagent profiles",
998
+ handler: async (_args, _ctx) => {
999
+ const profiles = listSubagentProfiles();
1000
+ if (profiles.length === 0) {
1001
+ sendSlashText(pi, "Subagent profiles\n\nNo subagent profiles found in ~/.pi/agent/profiles/pi-subagents/");
1002
+ return;
1003
+ }
1004
+ sendSlashText(pi, `Subagent profiles\n\n${profiles.join("\n")}`);
1005
+ },
1006
+ });
1007
+
1008
+ pi.registerCommand("subagents-load-profile", {
1009
+ description: "Load a subagent profile into ~/.pi/agent/settings.json",
1010
+ getArgumentCompletions: (prefix) => {
1011
+ if (prefix.includes(" ")) return null;
1012
+ return listSubagentProfiles()
1013
+ .filter((name) => name.startsWith(prefix))
1014
+ .map((name) => ({ value: name, label: name }));
1015
+ },
1016
+ handler: async (args, ctx) => {
1017
+ const parsed = parseSingleRequiredArg(args, "Usage: /subagents-load-profile <name>");
1018
+ if (!parsed.ok) {
1019
+ ctx.ui.notify(parsed.message, "error");
1020
+ return;
1021
+ }
1022
+ try {
1023
+ await withSlashStatus(ctx, `Loading profile ${parsed.value}…`, async () => {
1024
+ const { profile } = readSubagentProfile(parsed.value);
1025
+ const workerModel = getProfileWorkerModel(profile);
1026
+ const result = applySubagentProfile(parsed.value);
1027
+ const lines = [
1028
+ `Loaded subagent profile: ${parsed.value}`,
1029
+ `Profile: ${result.filePath}`,
1030
+ `Updated: ${result.settingsPath}`,
1031
+ ];
1032
+
1033
+ if (workerModel && typeof pi.setModel === "function" && typeof ctx.modelRegistry?.find === "function" && typeof ctx.modelRegistry?.getAvailable === "function") {
1034
+ const shouldSwitch = await ctx.ui.confirm(
1035
+ "",
1036
+ `Profile loaded. Also switch this session to the profile worker model?\n\n${workerModel}`,
1037
+ );
1038
+ if (shouldSwitch) {
1039
+ const modelInfo = findModelInfo(workerModel, ctx.modelRegistry.getAvailable().map(toModelInfo));
1040
+ const model = modelInfo ? ctx.modelRegistry.find(modelInfo.provider, modelInfo.id) : undefined;
1041
+ if (!modelInfo || !model) {
1042
+ lines.push(`Could not switch current session model: '${workerModel}' is not available in the current model registry.`);
1043
+ } else {
1044
+ const success = await pi.setModel(model);
1045
+ if (success) lines.push(`Current session model switched to: ${modelInfo.fullId}`);
1046
+ else lines.push(`Could not switch current session model to '${workerModel}': no API key or provider access is available.`);
1047
+ }
1048
+ }
1049
+ } else if (workerModel) {
1050
+ lines.push(`Profile worker model: ${workerModel}`);
1051
+ }
1052
+
1053
+ sendSlashText(pi, lines.join("\n"));
1054
+ });
1055
+ } catch (error) {
1056
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1057
+ }
1058
+ },
1059
+ });
1060
+
1061
+ pi.registerCommand("subagents-refresh-provider-models", {
1062
+ description: "Refresh the cached model catalog for one provider",
1063
+ getArgumentCompletions: makeProviderCompletions(state),
1064
+ handler: async (args, ctx) => {
1065
+ const trimmed = args.trim();
1066
+ const force = /(?:^|\s)--force$/.test(trimmed) || /(?:^|\s)force$/.test(trimmed);
1067
+ const withoutForce = trimmed.replace(/(?:^|\s)(?:--force|force)$/, "").trim();
1068
+ const parsed = parseSingleRequiredArg(withoutForce, "Usage: /subagents-refresh-provider-models <provider> [--force]");
1069
+ if (!parsed.ok) {
1070
+ ctx.ui.notify(parsed.message, "error");
1071
+ return;
1072
+ }
1073
+ try {
1074
+ await withSlashStatus(ctx, `Refreshing provider models for ${parsed.value}…`, async () => {
1075
+ const result = await refreshProviderModelCatalog(pi, ctx, parsed.value, { force, maxAgeDays: DEFAULT_PROVIDER_MODELS_MAX_AGE_DAYS });
1076
+ const lines = [
1077
+ "Provider model catalog",
1078
+ `Provider: ${parsed.value}`,
1079
+ `Status: ${result.reused ? "fresh cache reused" : "refreshed"}`,
1080
+ `File: ${result.filePath}`,
1081
+ `Models: ${result.catalog.models.length}`,
1082
+ `Refreshed at: ${result.catalog.refreshedAt}`,
1083
+ ];
1084
+ if (result.heuristicFallbackCount > 0) {
1085
+ lines.push(`Warning: ${result.heuristicFallbackCount} model${result.heuristicFallbackCount === 1 ? " was" : "s were"} classified with name heuristics fallback.`);
1086
+ }
1087
+ sendSlashText(pi, lines.join("\n"));
1088
+ });
1089
+ } catch (error) {
1090
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1091
+ }
1092
+ },
1093
+ });
1094
+
1095
+ pi.registerCommand("subagents-generate-profiles", {
1096
+ description: "Generate <provider>.quota and <provider>.quality subagent profiles",
1097
+ getArgumentCompletions: makeProviderCompletions(state),
1098
+ handler: async (args, ctx) => {
1099
+ const parsed = parseSingleRequiredArg(args, "Usage: /subagents-generate-profiles <provider>");
1100
+ if (!parsed.ok) {
1101
+ ctx.ui.notify(parsed.message, "error");
1102
+ return;
1103
+ }
1104
+ try {
1105
+ await withSlashStatus(ctx, `Generating profiles for ${parsed.value}…`, async () => {
1106
+ const result = await generateProfilesForProvider(pi, ctx, parsed.value, { maxAgeDays: DEFAULT_PROVIDER_MODELS_MAX_AGE_DAYS });
1107
+ const lines = [
1108
+ "Generated subagent profiles",
1109
+ `Provider: ${parsed.value}`,
1110
+ `Catalog: ${result.catalogPath}`,
1111
+ `Quota: ${result.quotaPath}`,
1112
+ ` cheap=${result.quotaModels.cheap}`,
1113
+ ` medium=${result.quotaModels.medium}`,
1114
+ ` strong=${result.quotaModels.strong}`,
1115
+ `Quality: ${result.qualityPath}`,
1116
+ ` cheap=${result.qualityModels.cheap}`,
1117
+ ` medium=${result.qualityModels.medium}`,
1118
+ ` strong=${result.qualityModels.strong}`,
1119
+ ];
1120
+ if (result.selectedHeuristicFallbackCount > 0) {
1121
+ lines.push(`Warning: generated profiles depend on heuristic-only classification for ${result.selectedHeuristicFallbackCount} selected model${result.selectedHeuristicFallbackCount === 1 ? "" : "s"}.`);
1122
+ } else if (result.heuristicFallbackCount > 0) {
1123
+ lines.push(`Warning: provider catalog still contains ${result.heuristicFallbackCount} heuristic-classified model${result.heuristicFallbackCount === 1 ? "" : "s"}.`);
1124
+ }
1125
+ sendSlashText(pi, lines.join("\n"));
1126
+ });
1127
+ } catch (error) {
1128
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1129
+ }
1130
+ },
1131
+ });
1132
+
1133
+ pi.registerCommand("subagents-check-profile", {
1134
+ description: "Check whether a saved profile still points to usable models",
1135
+ getArgumentCompletions: (prefix) => {
1136
+ if (prefix.includes(" ")) return null;
1137
+ return listSubagentProfiles()
1138
+ .filter((name) => name.startsWith(prefix))
1139
+ .map((name) => ({ value: name, label: name }));
1140
+ },
1141
+ handler: async (args, ctx) => {
1142
+ const parsed = parseSingleRequiredArg(args, "Usage: /subagents-check-profile <name>");
1143
+ if (!parsed.ok) {
1144
+ ctx.ui.notify(parsed.message, "error");
1145
+ return;
1146
+ }
1147
+ try {
1148
+ await withSlashStatus(ctx, `Checking profile ${parsed.value}…`, async () => {
1149
+ const result = await checkSubagentProfile(pi, ctx, parsed.value);
1150
+ const lines = [
1151
+ "Subagent profile check",
1152
+ `Profile: ${result.profileName}`,
1153
+ `File: ${result.filePath}`,
1154
+ "",
1155
+ ...result.results.map((entry) => `${entry.agent} → ${entry.model} — registry ${entry.inRegistry ? "ok" : "missing"}; probe ${entry.probe.status}${entry.probe.message ? ` (${entry.probe.message.split(/\r?\n/, 1)[0]})` : ""}`),
1156
+ ];
1157
+ sendSlashText(pi, lines.join("\n"));
1158
+ });
1159
+ } catch (error) {
1160
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1161
+ }
1162
+ },
1163
+ });
1164
+
566
1165
  }