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.
- package/CHANGELOG.md +48 -0
- package/README.md +170 -8
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +6 -1
- package/src/agents/agent-management.ts +6 -1
- package/src/agents/agents.ts +55 -11
- package/src/extension/companion-suggestions.ts +359 -0
- package/src/extension/config.ts +27 -4
- package/src/extension/doctor.ts +2 -0
- package/src/extension/fanout-child.ts +1 -0
- package/src/extension/index.ts +69 -4
- package/src/extension/schemas.ts +2 -2
- package/src/intercom/intercom-bridge.ts +25 -1
- package/src/profiles/profiles.ts +637 -0
- package/src/runs/background/async-execution.ts +138 -33
- package/src/runs/background/async-job-tracker.ts +77 -1
- package/src/runs/background/async-resume.ts +11 -13
- package/src/runs/background/async-status.ts +41 -9
- package/src/runs/background/chain-root-attachment.ts +34 -4
- package/src/runs/background/control-channel.ts +227 -0
- package/src/runs/background/run-status.ts +1 -0
- package/src/runs/background/stale-run-reconciler.ts +28 -1
- package/src/runs/background/subagent-runner.ts +459 -113
- package/src/runs/foreground/chain-execution.ts +29 -7
- package/src/runs/foreground/execution.ts +24 -6
- package/src/runs/foreground/subagent-executor.ts +240 -44
- package/src/runs/shared/acceptance.ts +45 -22
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/model-fallback.ts +4 -0
- package/src/runs/shared/nested-events.ts +58 -0
- package/src/runs/shared/parallel-utils.ts +49 -1
- package/src/runs/shared/pi-args.ts +5 -3
- package/src/runs/shared/pi-spawn.ts +52 -20
- package/src/runs/shared/single-output.ts +2 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
- package/src/runs/shared/worktree.ts +28 -5
- package/src/shared/artifacts.ts +15 -1
- package/src/shared/fork-context.ts +133 -22
- package/src/shared/types.ts +82 -3
- package/src/shared/utils.ts +99 -14
- package/src/slash/slash-commands.ts +726 -40
- 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
|
-
|
|
100
|
-
|
|
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 = (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
493
|
-
if (!
|
|
494
|
-
const
|
|
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
|
}
|