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.
- package/CHANGELOG.md +12 -0
- package/README.md +73 -1
- package/package.json +1 -1
- package/src/extension/index.ts +11 -0
- package/src/intercom/intercom-bridge.ts +25 -1
- package/src/profiles/profiles.ts +637 -0
- package/src/runs/background/async-resume.ts +11 -13
- package/src/runs/background/control-channel.ts +177 -0
- package/src/runs/background/subagent-runner.ts +7 -0
- package/src/runs/foreground/subagent-executor.ts +31 -9
- package/src/runs/shared/subagent-prompt-runtime.ts +1 -0
- package/src/shared/types.ts +1 -0
- package/src/slash/slash-commands.ts +609 -40
|
@@ -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
|
-
|
|
100
|
-
|
|
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 = (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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" };
|
|
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
|
}
|