pi-subagents 0.31.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.
@@ -4,9 +4,20 @@ 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
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
  };
@@ -132,6 +186,47 @@ const makeBuiltinAgentNameCompletions = () => (prefix: string) => {
132
186
  .map((name) => ({ value: name, label: name }));
133
187
  };
134
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
+
135
230
  function loadSavedOutputSchema(chain: ChainConfig, stepAgent: string, outputSchema: unknown): JsonSchemaObject | undefined {
136
231
  if (outputSchema === undefined) return undefined;
137
232
  if (typeof outputSchema === "string") {
@@ -174,6 +269,7 @@ const mapSavedChainSteps = (chain: ChainConfig, worktree = false): ChainStep[] =
174
269
  ...(step.label ? { label: step.label } : {}),
175
270
  ...(step.as ? { as: step.as } : {}),
176
271
  ...(outputSchema ? { outputSchema } : {}),
272
+ ...((step as { acceptance?: unknown }).acceptance !== undefined ? { acceptance: (step as { acceptance?: unknown }).acceptance } : {}),
177
273
  output: step.output,
178
274
  outputMode: step.outputMode,
179
275
  reads: step.reads,
@@ -369,7 +465,181 @@ async function runSlashSubagent(
369
465
  }
370
466
 
371
467
 
372
- 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
+ }
373
643
 
374
644
  const parseAgentArgs = (
375
645
  state: SubagentState,
@@ -390,23 +660,7 @@ const parseAgentArgs = (
390
660
  for (const seg of segments) {
391
661
  const trimmed = seg.trim();
392
662
  if (!trimmed) continue;
393
- let agentPart: string;
394
- let task: string | undefined;
395
- const qMatch = trimmed.match(/^(\S+(?:\[[^\]]*\])?)\s+(?:"([^"]*)"|'([^']*)')$/);
396
- if (qMatch) {
397
- agentPart = qMatch[1]!;
398
- task = (qMatch[2] ?? qMatch[3]) || undefined;
399
- } else {
400
- const dashIdx = trimmed.indexOf(" -- ");
401
- if (dashIdx !== -1) {
402
- agentPart = trimmed.slice(0, dashIdx).trim();
403
- task = trimmed.slice(dashIdx + 4).trim() || undefined;
404
- } else {
405
- agentPart = trimmed;
406
- }
407
- }
408
- const parsed = parseAgentToken(agentPart);
409
- steps.push({ ...parsed, task });
663
+ steps.push(parseSingleTaskToken(trimmed));
410
664
  }
411
665
  sharedTask = steps.find((s) => s.task)?.task ?? "";
412
666
  } else {
@@ -421,7 +675,7 @@ const parseAgentArgs = (
421
675
  ctx.ui.notify(usage, "error");
422
676
  return null;
423
677
  }
424
- steps = agentsPart.split(/\s+/).filter(Boolean).map((t) => parseAgentToken(t));
678
+ steps = agentsPart.split(/\s+/).filter(Boolean).map((t) => parseSingleTaskToken(t));
425
679
  }
426
680
 
427
681
  if (steps.length === 0) {
@@ -450,6 +704,162 @@ const parseAgentArgs = (
450
704
  return { steps, task: sharedTask };
451
705
  };
452
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
+
453
863
  export function registerSlashCommands(
454
864
  pi: ExtensionAPI,
455
865
  state: SubagentState,
@@ -489,19 +899,9 @@ export function registerSlashCommands(
489
899
  getArgumentCompletions: makeAgentCompletions(state, true),
490
900
  handler: async (args, ctx) => {
491
901
  const { args: cleanedArgs, bg, fork } = extractExecutionFlags(args);
492
- const parsed = parseAgentArgs(state, cleanedArgs, "chain", ctx);
493
- if (!parsed) return;
494
- const chain = parsed.steps.map(({ name, config, task: stepTask }, i) => ({
495
- agent: name,
496
- ...(stepTask ? { task: stepTask } : i === 0 && parsed.task ? { task: parsed.task } : {}),
497
- ...(config.output !== undefined ? { output: config.output } : {}),
498
- ...(config.outputMode !== undefined ? { outputMode: config.outputMode } : {}),
499
- ...(config.reads !== undefined ? { reads: config.reads } : {}),
500
- ...(config.model ? { model: config.model } : {}),
501
- ...(config.skill !== undefined ? { skill: config.skill } : {}),
502
- ...(config.progress !== undefined ? { progress: config.progress } : {}),
503
- }));
504
- 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" };
505
905
  if (bg) params.async = true;
506
906
  if (fork) params.context = "fork";
507
907
  await runSlashSubagent(pi, ctx, params);
@@ -593,4 +993,173 @@ export function registerSlashCommands(
593
993
  },
594
994
  });
595
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
+
596
1165
  }