pi-subagents 0.31.0 → 0.32.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +170 -8
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +6 -1
  5. package/src/agents/agent-management.ts +6 -1
  6. package/src/agents/agents.ts +55 -11
  7. package/src/extension/companion-suggestions.ts +359 -0
  8. package/src/extension/config.ts +27 -4
  9. package/src/extension/doctor.ts +2 -0
  10. package/src/extension/fanout-child.ts +1 -0
  11. package/src/extension/index.ts +69 -4
  12. package/src/extension/schemas.ts +2 -2
  13. package/src/intercom/intercom-bridge.ts +25 -1
  14. package/src/profiles/profiles.ts +637 -0
  15. package/src/runs/background/async-execution.ts +138 -33
  16. package/src/runs/background/async-job-tracker.ts +77 -1
  17. package/src/runs/background/async-resume.ts +11 -13
  18. package/src/runs/background/async-status.ts +41 -9
  19. package/src/runs/background/chain-root-attachment.ts +34 -4
  20. package/src/runs/background/control-channel.ts +227 -0
  21. package/src/runs/background/run-status.ts +1 -0
  22. package/src/runs/background/stale-run-reconciler.ts +28 -1
  23. package/src/runs/background/subagent-runner.ts +459 -113
  24. package/src/runs/foreground/chain-execution.ts +29 -7
  25. package/src/runs/foreground/execution.ts +24 -6
  26. package/src/runs/foreground/subagent-executor.ts +240 -44
  27. package/src/runs/shared/acceptance.ts +45 -22
  28. package/src/runs/shared/dynamic-fanout.ts +1 -1
  29. package/src/runs/shared/model-fallback.ts +4 -0
  30. package/src/runs/shared/nested-events.ts +58 -0
  31. package/src/runs/shared/parallel-utils.ts +49 -1
  32. package/src/runs/shared/pi-args.ts +5 -3
  33. package/src/runs/shared/pi-spawn.ts +52 -20
  34. package/src/runs/shared/single-output.ts +2 -0
  35. package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
  36. package/src/runs/shared/worktree.ts +28 -5
  37. package/src/shared/artifacts.ts +15 -1
  38. package/src/shared/fork-context.ts +133 -22
  39. package/src/shared/types.ts +82 -3
  40. package/src/shared/utils.ts +99 -14
  41. package/src/slash/slash-commands.ts +726 -40
  42. package/src/tui/render.ts +16 -4
@@ -4,26 +4,42 @@ 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";
19
+ import { formatTokens } from "../shared/formatters.ts";
9
20
  import { assertJsonSchemaObject } from "../runs/shared/structured-output.ts";
21
+ import { validateAcceptanceInput } from "../runs/shared/acceptance.ts";
10
22
  import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
11
23
  import {
12
24
  applySlashUpdate,
13
25
  buildSlashInitialResult,
14
26
  failSlashResult,
15
27
  finalizeSlashResult,
28
+ resolveSlashMessageDetails,
16
29
  } from "./slash-live-state.ts";
17
30
  import {
18
31
  SLASH_RESULT_TYPE,
32
+ SLASH_TEXT_RESULT_TYPE,
19
33
  SLASH_SUBAGENT_CANCEL_EVENT,
20
34
  SLASH_SUBAGENT_REQUEST_EVENT,
21
35
  SLASH_SUBAGENT_RESPONSE_EVENT,
22
36
  SLASH_SUBAGENT_STARTED_EVENT,
23
37
  SLASH_SUBAGENT_UPDATE_EVENT,
38
+ type Details,
24
39
  type JsonSchemaObject,
25
40
  type SingleResult,
26
41
  type SubagentState,
42
+ type Usage,
27
43
  } from "../shared/types.ts";
28
44
 
29
45
  interface InlineConfig {
@@ -33,6 +49,13 @@ interface InlineConfig {
33
49
  model?: string;
34
50
  skill?: string[] | false;
35
51
  progress?: boolean;
52
+ as?: string;
53
+ label?: string;
54
+ phase?: string;
55
+ cwd?: string;
56
+ count?: number;
57
+ outputSchema?: string;
58
+ acceptance?: string;
36
59
  }
37
60
 
38
61
  const parseInlineConfig = (raw: string): InlineConfig => {
@@ -54,6 +77,13 @@ const parseInlineConfig = (raw: string): InlineConfig => {
54
77
  case "model": config.model = val || undefined; break;
55
78
  case "skill": case "skills": config.skill = val === "false" ? false : val.split("+").filter(Boolean); break;
56
79
  case "progress": config.progress = val !== "false"; break;
80
+ case "as": config.as = val || undefined; break;
81
+ case "label": config.label = val || undefined; break;
82
+ case "phase": config.phase = val || undefined; break;
83
+ case "cwd": config.cwd = val || undefined; break;
84
+ case "count": { const n = Number(val); if (Number.isInteger(n) && n > 0) config.count = n; break; }
85
+ case "outputSchema": config.outputSchema = val || undefined; break;
86
+ case "acceptance": config.acceptance = val || undefined; break;
57
87
  }
58
88
  }
59
89
  return config;
@@ -96,16 +126,44 @@ const makeAgentCompletions = (state: SubagentState, multiAgent: boolean) => (pre
96
126
  return agents.filter((a) => a.name.startsWith(prefix)).map((a) => ({ value: a.name, label: a.name }));
97
127
  }
98
128
 
99
- const lastArrow = prefix.lastIndexOf(" -> ");
100
- const segment = lastArrow !== -1 ? prefix.slice(lastArrow + 4) : prefix;
129
+ // Find the start of the current chain step: after the last top-level `->` arrow or `(`,
130
+ // or after a `|` *inside* a group. A `|` at depth 0 is plain task text (only `(` opens a
131
+ // group), so it must not restart agent completion — otherwise `scout -- do x | wr` would
132
+ // wrongly resume suggesting agents past the `--` task. Quotes are tracked so separators
133
+ // inside a task are ignored.
134
+ let inSingle = false, inDouble = false, depth = 0, segStart = 0;
135
+ for (let i = 0; i < prefix.length; i++) {
136
+ const ch = prefix[i]!;
137
+ if (inSingle) { if (ch === "'") inSingle = false; continue; }
138
+ if (inDouble) { if (ch === '"') inDouble = false; continue; }
139
+ if (ch === "'") { inSingle = true; continue; }
140
+ if (ch === '"') { inDouble = true; continue; }
141
+ if (ch === "(") {
142
+ if (!prefix.slice(segStart, i).includes(" -- ")) {
143
+ depth++;
144
+ segStart = i + 1;
145
+ }
146
+ }
147
+ else if (ch === ")") {
148
+ if (depth > 0) {
149
+ depth--;
150
+ segStart = i + 1;
151
+ }
152
+ }
153
+ else if (ch === "|" && depth > 0) segStart = i + 1;
154
+ else if (ch === ">" && prefix[i - 1] === "-" && depth === 0) segStart = i + 1;
155
+ }
156
+ // Inside an open quote, or once the task has started (`--` / a quote), we are no
157
+ // longer typing an agent name.
158
+ if (inSingle || inDouble) return null;
159
+ const segment = prefix.slice(segStart);
101
160
  if (segment.includes(" -- ") || segment.includes('"') || segment.includes("'")) return null;
102
161
 
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
- }
162
+ const lastWord = (segment.match(/(\S*)$/) || ["", ""])[1];
163
+ let beforeLastWord = prefix.slice(0, prefix.length - lastWord.length);
164
+ // A bare `->` or `|` just typed (no trailing space) needs a separating space;
165
+ // `(` glues naturally to the agent name.
166
+ if (lastWord === "" && /[>|]$/.test(beforeLastWord)) beforeLastWord = `${beforeLastWord} `;
109
167
 
110
168
  return agents.filter((a) => a.name.startsWith(lastWord)).map((a) => ({ value: `${beforeLastWord}${a.name}`, label: a.name }));
111
169
  };
@@ -132,6 +190,154 @@ const makeBuiltinAgentNameCompletions = () => (prefix: string) => {
132
190
  .map((name) => ({ value: name, label: name }));
133
191
  };
134
192
 
193
+ const makeProviderCompletions = (state: SubagentState) => (prefix: string) => {
194
+ if (prefix.includes(" ")) return null;
195
+ const available = state.lastUiContext?.modelRegistry?.getAvailable?.();
196
+ if (!Array.isArray(available)) return null;
197
+ const providers = [...new Set(available
198
+ .map((model) => typeof model?.provider === "string" ? model.provider : "")
199
+ .filter(Boolean))]
200
+ .sort((a, b) => a.localeCompare(b));
201
+ return providers
202
+ .filter((provider) => provider.startsWith(prefix))
203
+ .map((provider) => ({ value: provider, label: provider }));
204
+ };
205
+
206
+ function sendSlashText(pi: ExtensionAPI, text: string): void {
207
+ pi.sendMessage({ customType: SLASH_TEXT_RESULT_TYPE, content: text, display: true });
208
+ }
209
+
210
+ async function withSlashStatus<T>(
211
+ ctx: ExtensionContext,
212
+ text: string,
213
+ run: () => Promise<T>,
214
+ ): Promise<T> {
215
+ if (ctx.hasUI) ctx.ui.setStatus("subagent-slash-text", text);
216
+ try {
217
+ return await run();
218
+ } finally {
219
+ if (ctx.hasUI) ctx.ui.setStatus("subagent-slash-text", undefined);
220
+ }
221
+ }
222
+
223
+ function emptyUsage(): Usage {
224
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
225
+ }
226
+
227
+ function addUsage(target: Usage, source: Usage): void {
228
+ target.input += source.input;
229
+ target.output += source.output;
230
+ target.cacheRead += source.cacheRead;
231
+ target.cacheWrite += source.cacheWrite;
232
+ target.cost += source.cost;
233
+ target.turns += source.turns;
234
+ }
235
+
236
+ function usageHasValue(usage: Usage): boolean {
237
+ return usage.input !== 0 || usage.output !== 0 || usage.cacheRead !== 0 || usage.cacheWrite !== 0 || usage.cost !== 0 || usage.turns !== 0;
238
+ }
239
+
240
+ function assistantUsageFromMessage(message: unknown): Usage | undefined {
241
+ if (!message || typeof message !== "object") return undefined;
242
+ const msg = message as { role?: unknown; usage?: unknown };
243
+ if (msg.role !== "assistant" || !msg.usage || typeof msg.usage !== "object") return undefined;
244
+ const usage = msg.usage as {
245
+ input?: unknown;
246
+ output?: unknown;
247
+ cacheRead?: unknown;
248
+ cacheWrite?: unknown;
249
+ cost?: { total?: unknown };
250
+ };
251
+ return {
252
+ input: typeof usage.input === "number" ? usage.input : 0,
253
+ output: typeof usage.output === "number" ? usage.output : 0,
254
+ cacheRead: typeof usage.cacheRead === "number" ? usage.cacheRead : 0,
255
+ cacheWrite: typeof usage.cacheWrite === "number" ? usage.cacheWrite : 0,
256
+ cost: typeof usage.cost?.total === "number" ? usage.cost.total : 0,
257
+ turns: 1,
258
+ };
259
+ }
260
+
261
+ function isSubagentDetails(value: unknown): value is Details {
262
+ if (!value || typeof value !== "object") return false;
263
+ const details = value as { mode?: unknown; results?: unknown };
264
+ return typeof details.mode === "string" && Array.isArray(details.results);
265
+ }
266
+
267
+ function detailsFromSessionEntry(entry: unknown): Details | undefined {
268
+ if (!entry || typeof entry !== "object") return undefined;
269
+ const record = entry as { type?: unknown; customType?: unknown; details?: unknown; message?: unknown };
270
+ if (record.type === "custom_message" && record.customType === SLASH_RESULT_TYPE) {
271
+ const details = resolveSlashMessageDetails(record.details)?.result.details;
272
+ return isSubagentDetails(details) ? details : undefined;
273
+ }
274
+ if (record.type !== "message" || !record.message || typeof record.message !== "object") return undefined;
275
+ const message = record.message as { role?: unknown; toolName?: unknown; details?: unknown };
276
+ if (message.role !== "toolResult" || message.toolName !== "subagent") return undefined;
277
+ return isSubagentDetails(message.details) ? message.details : undefined;
278
+ }
279
+
280
+ function formatCostUsage(label: string, usage: Usage): string {
281
+ const extras = [
282
+ usage.cacheRead ? `cache read ${formatTokens(usage.cacheRead)}` : "",
283
+ usage.cacheWrite ? `cache write ${formatTokens(usage.cacheWrite)}` : "",
284
+ usage.turns ? `${usage.turns} turn${usage.turns === 1 ? "" : "s"}` : "",
285
+ ].filter(Boolean);
286
+ return `${label}: ↑${formatTokens(usage.input)} ↓${formatTokens(usage.output)} $${usage.cost.toFixed(4)}${extras.length ? ` (${extras.join(", ")})` : ""}`;
287
+ }
288
+
289
+ function buildSubagentCostReport(ctx: ExtensionContext): string {
290
+ const parent = emptyUsage();
291
+ const childTotal = emptyUsage();
292
+ const total = emptyUsage();
293
+ const children: Array<{ label: string; usage: Usage; sessionFile?: string }> = [];
294
+ for (const entry of ctx.sessionManager.getBranch()) {
295
+ const message = entry.type === "message" ? (entry as { message?: unknown }).message : undefined;
296
+ const parentUsage = assistantUsageFromMessage(message);
297
+ if (parentUsage) addUsage(parent, parentUsage);
298
+ const details = detailsFromSessionEntry(entry);
299
+ if (!details) continue;
300
+ for (const result of details.results) {
301
+ if (!usageHasValue(result.usage)) continue;
302
+ const usage = { ...result.usage };
303
+ children.push({
304
+ label: `Child ${children.length + 1} (${result.agent})`,
305
+ usage,
306
+ ...(result.sessionFile ? { sessionFile: result.sessionFile } : {}),
307
+ });
308
+ addUsage(childTotal, usage);
309
+ }
310
+ }
311
+ addUsage(total, parent);
312
+ addUsage(total, childTotal);
313
+ const lines = [
314
+ "Subagent cost",
315
+ "",
316
+ formatCostUsage("Parent", parent),
317
+ ];
318
+ if (children.length === 0) {
319
+ lines.push("No subagent child usage found in this session.");
320
+ } else {
321
+ for (const child of children) {
322
+ lines.push(formatCostUsage(child.label, child.usage));
323
+ if (child.sessionFile) lines.push(` Session: ${child.sessionFile}`);
324
+ }
325
+ }
326
+ lines.push("────────────────────────────", formatCostUsage("Children", childTotal), formatCostUsage("Total", total));
327
+ return lines.join("\n");
328
+ }
329
+
330
+ function parseSingleRequiredArg(args: string, usage: string): { ok: true; value: string } | { ok: false; message: string } {
331
+ const parts = args.trim().split(/\s+/).filter(Boolean);
332
+ if (parts.length !== 1) return { ok: false, message: usage };
333
+ return { ok: true, value: parts[0]! };
334
+ }
335
+
336
+ function getProfileWorkerModel(profile: { subagents?: { agentOverrides?: Record<string, { model?: string }> } }): string | undefined {
337
+ const model = profile.subagents?.agentOverrides?.worker?.model;
338
+ return typeof model === "string" && model.trim() ? model.trim() : undefined;
339
+ }
340
+
135
341
  function loadSavedOutputSchema(chain: ChainConfig, stepAgent: string, outputSchema: unknown): JsonSchemaObject | undefined {
136
342
  if (outputSchema === undefined) return undefined;
137
343
  if (typeof outputSchema === "string") {
@@ -174,6 +380,7 @@ const mapSavedChainSteps = (chain: ChainConfig, worktree = false): ChainStep[] =
174
380
  ...(step.label ? { label: step.label } : {}),
175
381
  ...(step.as ? { as: step.as } : {}),
176
382
  ...(outputSchema ? { outputSchema } : {}),
383
+ ...((step as { acceptance?: unknown }).acceptance !== undefined ? { acceptance: (step as { acceptance?: unknown }).acceptance } : {}),
177
384
  output: step.output,
178
385
  outputMode: step.outputMode,
179
386
  reads: step.reads,
@@ -369,7 +576,181 @@ async function runSlashSubagent(
369
576
  }
370
577
 
371
578
 
372
- interface ParsedStep { name: string; config: InlineConfig; task?: string }
579
+ export interface GroupConfig { concurrency?: number; failFast?: boolean; worktree?: boolean }
580
+ export interface ParsedStep { kind: "step"; name: string; config: InlineConfig; task?: string }
581
+ export interface ParsedGroup { kind: "group"; tasks: ParsedStep[]; config: GroupConfig }
582
+ export type ParsedGroupStep = ParsedStep | ParsedGroup;
583
+
584
+ export const PARALLEL_GROUP_USAGE =
585
+ 'Usage: /chain agent "task" -> (agent2 "task" | agent3 "task") -> agent4';
586
+
587
+ export class SlashParseError extends Error {}
588
+
589
+ // Walk `input` tracking quote/paren state; returns true if parens are unbalanced.
590
+ function findUnmatchedCloseParen(input: string): boolean {
591
+ let depth = 0, inSingle = false, inDouble = false;
592
+ for (let i = 0; i < input.length; i++) {
593
+ const ch = input[i]!;
594
+ if (inSingle) { if (ch === "'") inSingle = false; continue; }
595
+ if (inDouble) { if (ch === '"') inDouble = false; continue; }
596
+ if (ch === "'") { inSingle = true; continue; }
597
+ if (ch === '"') { inDouble = true; continue; }
598
+ if (ch === "(") depth++;
599
+ else if (ch === ")") { depth--; if (depth < 0) return true; }
600
+ }
601
+ return depth !== 0;
602
+ }
603
+
604
+ // Split on top-level " -> ", ignoring arrows inside quotes or parentheses.
605
+ function splitOnArrow(input: string): string[] {
606
+ const segments: string[] = [];
607
+ let depth = 0, inSingle = false, inDouble = false, start = 0;
608
+ for (let i = 0; i < input.length; i++) {
609
+ const ch = input[i]!;
610
+ if (inSingle) { if (ch === "'") inSingle = false; continue; }
611
+ if (inDouble) { if (ch === '"') inDouble = false; continue; }
612
+ if (ch === "'") { inSingle = true; continue; }
613
+ if (ch === '"') { inDouble = true; continue; }
614
+ if (ch === "(") depth++;
615
+ else if (ch === ")") depth--;
616
+ else if (depth === 0 && ch === "-" && input[i + 1] === ">" && input[i + 2] === " ") {
617
+ segments.push(input.slice(start, i));
618
+ i += 2;
619
+ start = i + 1;
620
+ }
621
+ }
622
+ segments.push(input.slice(start));
623
+ return segments;
624
+ }
625
+
626
+ // Split a group's inner text on top-level " | ", ignoring pipes inside quotes/parens.
627
+ function splitGroupTasks(inner: string): string[] {
628
+ const parts: string[] = [];
629
+ let depth = 0, inSingle = false, inDouble = false, start = 0;
630
+ for (let i = 0; i < inner.length; i++) {
631
+ const ch = inner[i]!;
632
+ if (inSingle) { if (ch === "'") inSingle = false; continue; }
633
+ if (inDouble) { if (ch === '"') inDouble = false; continue; }
634
+ if (ch === "'") { inSingle = true; continue; }
635
+ if (ch === '"') { inDouble = true; continue; }
636
+ if (ch === "(") depth++;
637
+ else if (ch === ")") depth--;
638
+ else if (ch === "|" && depth === 0) {
639
+ parts.push(inner.slice(start, i));
640
+ start = i + 1;
641
+ }
642
+ }
643
+ parts.push(inner.slice(start));
644
+ return parts;
645
+ }
646
+
647
+ export function parseSingleTaskToken(token: string): ParsedStep {
648
+ let agentPart: string;
649
+ let task: string | undefined;
650
+ const qMatch = token.match(/^(\S+(?:\[[^\]]*\])?)\s+(?:"([^"]*)"|'([^']*)')$/);
651
+ if (qMatch) {
652
+ agentPart = qMatch[1]!;
653
+ task = (qMatch[2] ?? qMatch[3]) || undefined;
654
+ } else {
655
+ const dashIdx = token.indexOf(" -- ");
656
+ if (dashIdx !== -1) {
657
+ agentPart = token.slice(0, dashIdx).trim();
658
+ task = token.slice(dashIdx + 4).trim() || undefined;
659
+ } else {
660
+ agentPart = token;
661
+ }
662
+ }
663
+ return { kind: "step", ...parseAgentToken(agentPart), task };
664
+ }
665
+
666
+ const parseGroupConfig = (raw: string): GroupConfig => {
667
+ const config: GroupConfig = {};
668
+ for (const part of raw.split(",")) {
669
+ const trimmed = part.trim();
670
+ if (!trimmed) continue;
671
+ const eq = trimmed.indexOf("=");
672
+ const key = eq === -1 ? trimmed : trimmed.slice(0, eq).trim();
673
+ const val = eq === -1 ? "" : trimmed.slice(eq + 1).trim();
674
+ switch (key) {
675
+ case "concurrency": { const n = Number(val); if (Number.isInteger(n) && n > 0) config.concurrency = n; break; }
676
+ case "failFast": config.failFast = eq === -1 ? true : val !== "false"; break;
677
+ case "worktree": config.worktree = eq === -1 ? true : val !== "false"; break;
678
+ }
679
+ }
680
+ return config;
681
+ };
682
+
683
+ // Split `(...)` from an optional trailing `[...]` group-config suffix, respecting
684
+ // quotes and nested parens. Returns the inner group text and the parsed config.
685
+ const splitGroupBody = (trimmed: string): { inner: string; config: GroupConfig } => {
686
+ let depth = 0, inSingle = false, inDouble = false, closeIdx = -1;
687
+ for (let i = 0; i < trimmed.length; i++) {
688
+ const ch = trimmed[i]!;
689
+ if (inSingle) { if (ch === "'") inSingle = false; continue; }
690
+ if (inDouble) { if (ch === '"') inDouble = false; continue; }
691
+ if (ch === "'") { inSingle = true; continue; }
692
+ if (ch === '"') { inDouble = true; continue; }
693
+ if (ch === "(") depth++;
694
+ else if (ch === ")") { depth--; if (depth === 0) { closeIdx = i; break; } }
695
+ }
696
+ if (closeIdx === -1) throw new SlashParseError(`Unmatched parentheses in group: '${trimmed}'`);
697
+ const inner = trimmed.slice(1, closeIdx);
698
+ const suffix = trimmed.slice(closeIdx + 1).trim();
699
+ if (!suffix) return { inner, config: {} };
700
+ if (!suffix.startsWith("[") || !suffix.endsWith("]")) {
701
+ throw new SlashParseError(`Group options must be wrapped in [...]: '${suffix}'`);
702
+ }
703
+ return { inner, config: parseGroupConfig(suffix.slice(1, -1)) };
704
+ };
705
+
706
+ export function parseGroupSegment(segment: string): ParsedGroup {
707
+ const trimmed = segment.trim();
708
+ if (!trimmed.startsWith("(")) {
709
+ throw new SlashParseError(`Parallel group must be wrapped in parentheses: '${trimmed}'`);
710
+ }
711
+ const { inner, config } = splitGroupBody(trimmed);
712
+ const rawParts = splitGroupTasks(inner).map((p) => p.trim()).filter((p) => p.length > 0);
713
+ if (rawParts.length < 2) {
714
+ throw new SlashParseError("Parallel group must contain at least two tasks separated by ' | '");
715
+ }
716
+ return { kind: "group", tasks: rawParts.map((part) => parseSingleTaskToken(part)), config };
717
+ }
718
+
719
+ // True if `input` uses inline parallel-group syntax. A group is a *step* that begins
720
+ // with `(` at the top level, so we split on top-level ` -> ` arrows and look for a
721
+ // segment that opens with `(`. Parentheses appearing inside a task (e.g.
722
+ // `scout -- inspect auth (backend)`) do not count, which keeps legacy shared-task
723
+ // `-- task` parsing — including a bare `|` inside the task — on the single-agent path.
724
+ export function hasGroupSyntax(input: string): boolean {
725
+ return splitOnArrow(input).some((seg) => seg.trim().startsWith("("));
726
+ }
727
+
728
+ export function parseChainExpression(input: string): { steps: ParsedGroupStep[] } {
729
+ const trimmed = input.trim();
730
+ if (!trimmed.includes(" -> ")) {
731
+ throw new SlashParseError('Parallel groups in /chain require " -> " between steps');
732
+ }
733
+ if (findUnmatchedCloseParen(trimmed)) {
734
+ throw new SlashParseError("Unmatched parentheses in /chain expression");
735
+ }
736
+ const steps: ParsedGroupStep[] = [];
737
+ for (const seg of splitOnArrow(trimmed)) {
738
+ const t = seg.trim();
739
+ if (!t) continue;
740
+ if (t.startsWith("(")) {
741
+ steps.push(parseGroupSegment(t));
742
+ continue;
743
+ }
744
+ if (findUnmatchedCloseParen(t)) {
745
+ throw new SlashParseError(`Unmatched parentheses in chain segment: '${t}'`);
746
+ }
747
+ steps.push(parseSingleTaskToken(t));
748
+ }
749
+ if (steps.length === 0) {
750
+ throw new SlashParseError("/chain expression must include at least one step");
751
+ }
752
+ return { steps };
753
+ }
373
754
 
374
755
  const parseAgentArgs = (
375
756
  state: SubagentState,
@@ -390,23 +771,7 @@ const parseAgentArgs = (
390
771
  for (const seg of segments) {
391
772
  const trimmed = seg.trim();
392
773
  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 });
774
+ steps.push(parseSingleTaskToken(trimmed));
410
775
  }
411
776
  sharedTask = steps.find((s) => s.task)?.task ?? "";
412
777
  } else {
@@ -421,7 +786,7 @@ const parseAgentArgs = (
421
786
  ctx.ui.notify(usage, "error");
422
787
  return null;
423
788
  }
424
- steps = agentsPart.split(/\s+/).filter(Boolean).map((t) => parseAgentToken(t));
789
+ steps = agentsPart.split(/\s+/).filter(Boolean).map((t) => parseSingleTaskToken(t));
425
790
  }
426
791
 
427
792
  if (steps.length === 0) {
@@ -450,6 +815,162 @@ const parseAgentArgs = (
450
815
  return { steps, task: sharedTask };
451
816
  };
452
817
 
818
+ type ChainStepObject = {
819
+ agent: string;
820
+ task?: string;
821
+ output?: string | false;
822
+ outputMode?: "inline" | "file-only";
823
+ reads?: string[] | false;
824
+ model?: string;
825
+ skill?: string[] | false;
826
+ progress?: boolean;
827
+ as?: string;
828
+ label?: string;
829
+ phase?: string;
830
+ cwd?: string;
831
+ count?: number;
832
+ outputSchema?: JsonSchemaObject;
833
+ acceptance?: string;
834
+ };
835
+
836
+ const INLINE_ACCEPTANCE_LEVELS = new Set(["auto", "attested", "checked"]);
837
+
838
+ function validateInlineAcceptanceInput(value: string, agent: string): void {
839
+ const errors = validateAcceptanceInput(value, `acceptance for step '${agent}'`);
840
+ if (errors.length > 0) throw new SlashParseError(errors[0]!);
841
+ if (!INLINE_ACCEPTANCE_LEVELS.has(value)) {
842
+ 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.`);
843
+ }
844
+ }
845
+
846
+ // Load an inline `outputSchema=<path>` JSON file, resolved against the session cwd.
847
+ // Throws (SlashParseError / fs / JSON) on a missing or malformed schema.
848
+ function loadInlineOutputSchema(baseCwd: string, agent: string, value: string): JsonSchemaObject {
849
+ const schemaPath = path.isAbsolute(value) ? value : path.join(baseCwd, value);
850
+ const label = `outputSchema for step '${agent}' (${schemaPath})`;
851
+ let parsed: unknown;
852
+ try {
853
+ parsed = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
854
+ } catch (error) {
855
+ throw new SlashParseError(`Cannot read ${label}: ${error instanceof Error ? error.message : String(error)}`);
856
+ }
857
+ assertJsonSchemaObject(parsed, label);
858
+ return parsed;
859
+ }
860
+
861
+ // Build a ChainStep object from a parsed token. `inGroup` enables `count` (parallel-only).
862
+ // May throw SlashParseError for an invalid acceptance level or outputSchema path.
863
+ const mapParsedTaskToStepObject = (
864
+ step: ParsedStep,
865
+ fallbackTask: string | undefined,
866
+ isFirst: boolean,
867
+ opts: { baseCwd: string; inGroup: boolean },
868
+ ): ChainStepObject => {
869
+ const { name, config, task: stepTask } = step;
870
+ if (config.acceptance !== undefined) validateInlineAcceptanceInput(config.acceptance, name);
871
+ return {
872
+ agent: name,
873
+ ...(stepTask ? { task: stepTask } : isFirst && fallbackTask ? { task: fallbackTask } : {}),
874
+ ...(config.output !== undefined ? { output: config.output } : {}),
875
+ ...(config.outputMode !== undefined ? { outputMode: config.outputMode } : {}),
876
+ ...(config.reads !== undefined ? { reads: config.reads } : {}),
877
+ ...(config.model ? { model: config.model } : {}),
878
+ ...(config.skill !== undefined ? { skill: config.skill } : {}),
879
+ ...(config.progress !== undefined ? { progress: config.progress } : {}),
880
+ ...(config.as ? { as: config.as } : {}),
881
+ ...(config.label ? { label: config.label } : {}),
882
+ ...(config.phase ? { phase: config.phase } : {}),
883
+ ...(config.cwd ? { cwd: config.cwd } : {}),
884
+ ...(opts.inGroup && config.count !== undefined ? { count: config.count } : {}),
885
+ ...(config.outputSchema ? { outputSchema: loadInlineOutputSchema(opts.baseCwd, name, config.outputSchema) } : {}),
886
+ ...(config.acceptance ? { acceptance: config.acceptance } : {}),
887
+ };
888
+ };
889
+
890
+ export function buildChainExpressionSteps(
891
+ state: SubagentState,
892
+ input: string,
893
+ ctx: ExtensionContext,
894
+ ): { chain: ChainStep[]; task: string } | null {
895
+ const notify = (message: string) => ctx.ui.notify(message, "error");
896
+ if (!hasGroupSyntax(input)) {
897
+ const parsed = parseAgentArgs(state, input, "chain", ctx);
898
+ if (!parsed) return null;
899
+ const baseCwd = state.baseCwd!; // parseAgentArgs already verified baseCwd is set
900
+ try {
901
+ const chain: ChainStep[] = parsed.steps.map((step, i) =>
902
+ mapParsedTaskToStepObject(step, parsed.task || undefined, i === 0, { baseCwd, inGroup: false }),
903
+ );
904
+ return { chain, task: parsed.task };
905
+ } catch (error) {
906
+ notify(error instanceof Error ? error.message : String(error));
907
+ return null;
908
+ }
909
+ }
910
+
911
+ let expression: { steps: ParsedGroupStep[] };
912
+ try {
913
+ expression = parseChainExpression(input);
914
+ } catch (error) {
915
+ notify(error instanceof Error ? error.message : String(error));
916
+ return null;
917
+ }
918
+ if (!state.baseCwd) {
919
+ notify("Subagent session cwd is not initialized yet");
920
+ return null;
921
+ }
922
+ const agents = discoverAgents(state.baseCwd, "both").agents;
923
+ const stepAgentNames = expression.steps.flatMap((step) =>
924
+ step.kind === "group" ? step.tasks.map((t) => t.name) : [step.name],
925
+ );
926
+ for (const name of stepAgentNames) {
927
+ if (!agents.find((a) => a.name === name)) {
928
+ notify(`Unknown agent: ${name}`);
929
+ return null;
930
+ }
931
+ }
932
+ // Every task inside a parallel group needs its own task; there is no shared-task fallback.
933
+ for (const step of expression.steps) {
934
+ if (step.kind === "group" && step.tasks.some((t) => !t.task)) {
935
+ notify('Each task in a parallel group needs a task: (agent "a" | agent "b")');
936
+ return null;
937
+ }
938
+ }
939
+ const firstStep = expression.steps[0]!;
940
+ const firstHasTask =
941
+ firstStep.kind === "group"
942
+ ? firstStep.tasks.some((t) => Boolean(t.task))
943
+ : Boolean(firstStep.task);
944
+ if (!firstHasTask) {
945
+ notify('First step must have a task: /chain agent "task" -> agent2');
946
+ return null;
947
+ }
948
+ const sharedTask =
949
+ firstStep.kind === "group"
950
+ ? (firstStep.tasks.find((t) => t.task)?.task ?? "")
951
+ : (firstStep.task ?? "");
952
+ const baseCwd = state.baseCwd;
953
+ let chain: ChainStep[];
954
+ try {
955
+ chain = expression.steps.map((step) => {
956
+ if (step.kind === "group") {
957
+ const parallel = step.tasks.map((t) => mapParsedTaskToStepObject(t, undefined, false, { baseCwd, inGroup: true }));
958
+ return {
959
+ parallel,
960
+ ...(step.config.concurrency !== undefined ? { concurrency: step.config.concurrency } : {}),
961
+ ...(step.config.failFast !== undefined ? { failFast: step.config.failFast } : {}),
962
+ ...(step.config.worktree !== undefined ? { worktree: step.config.worktree } : {}),
963
+ };
964
+ }
965
+ return mapParsedTaskToStepObject(step, sharedTask || undefined, false, { baseCwd, inGroup: false });
966
+ });
967
+ } catch (error) {
968
+ notify(error instanceof Error ? error.message : String(error));
969
+ return null;
970
+ }
971
+ return { chain, task: sharedTask };
972
+ }
973
+
453
974
  export function registerSlashCommands(
454
975
  pi: ExtensionAPI,
455
976
  state: SubagentState,
@@ -489,19 +1010,9 @@ export function registerSlashCommands(
489
1010
  getArgumentCompletions: makeAgentCompletions(state, true),
490
1011
  handler: async (args, ctx) => {
491
1012
  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" };
1013
+ const built = buildChainExpressionSteps(state, cleanedArgs, ctx);
1014
+ if (!built) return;
1015
+ const params: SubagentParamsLike = { chain: built.chain, task: built.task, clarify: false, agentScope: "both" };
505
1016
  if (bg) params.async = true;
506
1017
  if (fork) params.context = "fork";
507
1018
  await runSlashSubagent(pi, ctx, params);
@@ -562,6 +1073,12 @@ export function registerSlashCommands(
562
1073
  },
563
1074
  });
564
1075
 
1076
+ pi.registerCommand("subagent-cost", {
1077
+ description: "Show parent and subagent child usage cost for this session",
1078
+ handler: async (_args, ctx) => {
1079
+ sendSlashText(pi, buildSubagentCostReport(ctx));
1080
+ },
1081
+ });
565
1082
 
566
1083
  pi.registerCommand("subagents-doctor", {
567
1084
  description: "Show subagent diagnostics",
@@ -593,4 +1110,173 @@ export function registerSlashCommands(
593
1110
  },
594
1111
  });
595
1112
 
1113
+ pi.registerCommand("subagents-profiles", {
1114
+ description: "List saved subagent profiles",
1115
+ handler: async (_args, _ctx) => {
1116
+ const profiles = listSubagentProfiles();
1117
+ if (profiles.length === 0) {
1118
+ sendSlashText(pi, "Subagent profiles\n\nNo subagent profiles found in ~/.pi/agent/profiles/pi-subagents/");
1119
+ return;
1120
+ }
1121
+ sendSlashText(pi, `Subagent profiles\n\n${profiles.join("\n")}`);
1122
+ },
1123
+ });
1124
+
1125
+ pi.registerCommand("subagents-load-profile", {
1126
+ description: "Load a subagent profile into ~/.pi/agent/settings.json",
1127
+ getArgumentCompletions: (prefix) => {
1128
+ if (prefix.includes(" ")) return null;
1129
+ return listSubagentProfiles()
1130
+ .filter((name) => name.startsWith(prefix))
1131
+ .map((name) => ({ value: name, label: name }));
1132
+ },
1133
+ handler: async (args, ctx) => {
1134
+ const parsed = parseSingleRequiredArg(args, "Usage: /subagents-load-profile <name>");
1135
+ if (!parsed.ok) {
1136
+ ctx.ui.notify(parsed.message, "error");
1137
+ return;
1138
+ }
1139
+ try {
1140
+ await withSlashStatus(ctx, `Loading profile ${parsed.value}…`, async () => {
1141
+ const { profile } = readSubagentProfile(parsed.value);
1142
+ const workerModel = getProfileWorkerModel(profile);
1143
+ const result = applySubagentProfile(parsed.value);
1144
+ const lines = [
1145
+ `Loaded subagent profile: ${parsed.value}`,
1146
+ `Profile: ${result.filePath}`,
1147
+ `Updated: ${result.settingsPath}`,
1148
+ ];
1149
+
1150
+ if (workerModel && typeof pi.setModel === "function" && typeof ctx.modelRegistry?.find === "function" && typeof ctx.modelRegistry?.getAvailable === "function") {
1151
+ const shouldSwitch = await ctx.ui.confirm(
1152
+ "",
1153
+ `Profile loaded. Also switch this session to the profile worker model?\n\n${workerModel}`,
1154
+ );
1155
+ if (shouldSwitch) {
1156
+ const modelInfo = findModelInfo(workerModel, ctx.modelRegistry.getAvailable().map(toModelInfo));
1157
+ const model = modelInfo ? ctx.modelRegistry.find(modelInfo.provider, modelInfo.id) : undefined;
1158
+ if (!modelInfo || !model) {
1159
+ lines.push(`Could not switch current session model: '${workerModel}' is not available in the current model registry.`);
1160
+ } else {
1161
+ const success = await pi.setModel(model);
1162
+ if (success) lines.push(`Current session model switched to: ${modelInfo.fullId}`);
1163
+ else lines.push(`Could not switch current session model to '${workerModel}': no API key or provider access is available.`);
1164
+ }
1165
+ }
1166
+ } else if (workerModel) {
1167
+ lines.push(`Profile worker model: ${workerModel}`);
1168
+ }
1169
+
1170
+ sendSlashText(pi, lines.join("\n"));
1171
+ });
1172
+ } catch (error) {
1173
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1174
+ }
1175
+ },
1176
+ });
1177
+
1178
+ pi.registerCommand("subagents-refresh-provider-models", {
1179
+ description: "Refresh the cached model catalog for one provider",
1180
+ getArgumentCompletions: makeProviderCompletions(state),
1181
+ handler: async (args, ctx) => {
1182
+ const trimmed = args.trim();
1183
+ const force = /(?:^|\s)--force$/.test(trimmed) || /(?:^|\s)force$/.test(trimmed);
1184
+ const withoutForce = trimmed.replace(/(?:^|\s)(?:--force|force)$/, "").trim();
1185
+ const parsed = parseSingleRequiredArg(withoutForce, "Usage: /subagents-refresh-provider-models <provider> [--force]");
1186
+ if (!parsed.ok) {
1187
+ ctx.ui.notify(parsed.message, "error");
1188
+ return;
1189
+ }
1190
+ try {
1191
+ await withSlashStatus(ctx, `Refreshing provider models for ${parsed.value}…`, async () => {
1192
+ const result = await refreshProviderModelCatalog(pi, ctx, parsed.value, { force, maxAgeDays: DEFAULT_PROVIDER_MODELS_MAX_AGE_DAYS });
1193
+ const lines = [
1194
+ "Provider model catalog",
1195
+ `Provider: ${parsed.value}`,
1196
+ `Status: ${result.reused ? "fresh cache reused" : "refreshed"}`,
1197
+ `File: ${result.filePath}`,
1198
+ `Models: ${result.catalog.models.length}`,
1199
+ `Refreshed at: ${result.catalog.refreshedAt}`,
1200
+ ];
1201
+ if (result.heuristicFallbackCount > 0) {
1202
+ lines.push(`Warning: ${result.heuristicFallbackCount} model${result.heuristicFallbackCount === 1 ? " was" : "s were"} classified with name heuristics fallback.`);
1203
+ }
1204
+ sendSlashText(pi, lines.join("\n"));
1205
+ });
1206
+ } catch (error) {
1207
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1208
+ }
1209
+ },
1210
+ });
1211
+
1212
+ pi.registerCommand("subagents-generate-profiles", {
1213
+ description: "Generate <provider>.quota and <provider>.quality subagent profiles",
1214
+ getArgumentCompletions: makeProviderCompletions(state),
1215
+ handler: async (args, ctx) => {
1216
+ const parsed = parseSingleRequiredArg(args, "Usage: /subagents-generate-profiles <provider>");
1217
+ if (!parsed.ok) {
1218
+ ctx.ui.notify(parsed.message, "error");
1219
+ return;
1220
+ }
1221
+ try {
1222
+ await withSlashStatus(ctx, `Generating profiles for ${parsed.value}…`, async () => {
1223
+ const result = await generateProfilesForProvider(pi, ctx, parsed.value, { maxAgeDays: DEFAULT_PROVIDER_MODELS_MAX_AGE_DAYS });
1224
+ const lines = [
1225
+ "Generated subagent profiles",
1226
+ `Provider: ${parsed.value}`,
1227
+ `Catalog: ${result.catalogPath}`,
1228
+ `Quota: ${result.quotaPath}`,
1229
+ ` cheap=${result.quotaModels.cheap}`,
1230
+ ` medium=${result.quotaModels.medium}`,
1231
+ ` strong=${result.quotaModels.strong}`,
1232
+ `Quality: ${result.qualityPath}`,
1233
+ ` cheap=${result.qualityModels.cheap}`,
1234
+ ` medium=${result.qualityModels.medium}`,
1235
+ ` strong=${result.qualityModels.strong}`,
1236
+ ];
1237
+ if (result.selectedHeuristicFallbackCount > 0) {
1238
+ lines.push(`Warning: generated profiles depend on heuristic-only classification for ${result.selectedHeuristicFallbackCount} selected model${result.selectedHeuristicFallbackCount === 1 ? "" : "s"}.`);
1239
+ } else if (result.heuristicFallbackCount > 0) {
1240
+ lines.push(`Warning: provider catalog still contains ${result.heuristicFallbackCount} heuristic-classified model${result.heuristicFallbackCount === 1 ? "" : "s"}.`);
1241
+ }
1242
+ sendSlashText(pi, lines.join("\n"));
1243
+ });
1244
+ } catch (error) {
1245
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1246
+ }
1247
+ },
1248
+ });
1249
+
1250
+ pi.registerCommand("subagents-check-profile", {
1251
+ description: "Check whether a saved profile still points to usable models",
1252
+ getArgumentCompletions: (prefix) => {
1253
+ if (prefix.includes(" ")) return null;
1254
+ return listSubagentProfiles()
1255
+ .filter((name) => name.startsWith(prefix))
1256
+ .map((name) => ({ value: name, label: name }));
1257
+ },
1258
+ handler: async (args, ctx) => {
1259
+ const parsed = parseSingleRequiredArg(args, "Usage: /subagents-check-profile <name>");
1260
+ if (!parsed.ok) {
1261
+ ctx.ui.notify(parsed.message, "error");
1262
+ return;
1263
+ }
1264
+ try {
1265
+ await withSlashStatus(ctx, `Checking profile ${parsed.value}…`, async () => {
1266
+ const result = await checkSubagentProfile(pi, ctx, parsed.value);
1267
+ const lines = [
1268
+ "Subagent profile check",
1269
+ `Profile: ${result.profileName}`,
1270
+ `File: ${result.filePath}`,
1271
+ "",
1272
+ ...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]})` : ""}`),
1273
+ ];
1274
+ sendSlashText(pi, lines.join("\n"));
1275
+ });
1276
+ } catch (error) {
1277
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1278
+ }
1279
+ },
1280
+ });
1281
+
596
1282
  }