pi-subagents-lite 0.2.0 → 0.3.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/README.md +222 -36
- package/package.json +3 -1
- package/src/agent-discovery.ts +36 -45
- package/src/agent-manager.ts +101 -87
- package/src/agent-runner.ts +40 -49
- package/src/agent-types.ts +15 -37
- package/src/config-io.ts +40 -0
- package/src/context.ts +80 -1
- package/src/index.ts +105 -1117
- package/src/menus.ts +866 -0
- package/src/model-precedence.ts +46 -36
- package/src/model-selector.ts +19 -19
- package/src/output-file.ts +123 -33
- package/src/prompts.ts +2 -2
- package/src/result-viewer.ts +166 -37
- package/src/skill-loader.ts +1 -1
- package/src/stop-agent-tool.ts +76 -0
- package/src/tool-execution.ts +361 -0
- package/src/types.ts +16 -1
- package/src/ui/agent-widget.ts +98 -91
- package/src/usage.ts +12 -4
- package/src/utils.ts +53 -4
package/src/menus.ts
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* menus.ts — /agents command menu system.
|
|
3
|
+
*
|
|
4
|
+
* All menu-related functions extracted from index.ts.
|
|
5
|
+
* Imports shared state (config, manager, piInstance) from index.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { getAgentConfig, getAvailableTypes, getAllTypes } from "./agent-types.js";
|
|
10
|
+
import type { AgentRecord } from "./types.js";
|
|
11
|
+
import { ModelSelectorDialog, type ModelOption } from "./model-selector.js";
|
|
12
|
+
import { ResultViewer, type ResultViewerStats } from "./result-viewer.js";
|
|
13
|
+
import { getDisplayName } from "./ui/agent-widget.js";
|
|
14
|
+
import { buildSnapshotMarkdown } from "./context.js";
|
|
15
|
+
|
|
16
|
+
import { parseModelKey, errorMessage } from "./utils.js";
|
|
17
|
+
import {
|
|
18
|
+
__config,
|
|
19
|
+
sessionOverrides,
|
|
20
|
+
manager,
|
|
21
|
+
piInstance,
|
|
22
|
+
} from "./index.js";
|
|
23
|
+
import { resolveModel } from "./model-precedence.js";
|
|
24
|
+
import { saveConfigAtomic } from "./config-io.js";
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Helpers
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build ModelOption[] from raw "provider/model-id" strings.
|
|
32
|
+
* Includes "(inherits parent)" as the first option.
|
|
33
|
+
*/
|
|
34
|
+
function buildModelOptions(rawOptions: string[]): ModelOption[] {
|
|
35
|
+
const items: ModelOption[] = [
|
|
36
|
+
{ value: "(inherits parent)", label: "(inherits parent)", provider: "" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const opt of rawOptions) {
|
|
40
|
+
const parsed = parseModelKey(opt);
|
|
41
|
+
if (!parsed) continue;
|
|
42
|
+
items.push({ value: opt, label: parsed.modelId, provider: parsed.provider });
|
|
43
|
+
}
|
|
44
|
+
return items;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Show the ModelSelectorDialog and return the chosen model string, or null.
|
|
49
|
+
*/
|
|
50
|
+
async function promptModelSelection(
|
|
51
|
+
ctx: ExtensionCommandContext,
|
|
52
|
+
modelOptions: string[],
|
|
53
|
+
currentValue: string,
|
|
54
|
+
): Promise<string | null> {
|
|
55
|
+
return ctx.ui.custom<string | null>(
|
|
56
|
+
(_tui, theme, _kb, done) => {
|
|
57
|
+
const opts = buildModelOptions(modelOptions);
|
|
58
|
+
return new ModelSelectorDialog(opts, currentValue, {
|
|
59
|
+
onSelect: (m) => done(m),
|
|
60
|
+
onCancel: () => done(null),
|
|
61
|
+
}, theme);
|
|
62
|
+
}, // no overlay — renders inline below editor, matching pi's model selector look and feel
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Prompt user to choose between session-only or permanent persistence.
|
|
68
|
+
* When showClear is true, also offers "Clear".
|
|
69
|
+
* Returns "session", "permanent", "clear", or null if cancelled.
|
|
70
|
+
*/
|
|
71
|
+
async function promptOverrideMode(
|
|
72
|
+
ctx: ExtensionCommandContext,
|
|
73
|
+
showClear: boolean = false,
|
|
74
|
+
): Promise<"session" | "permanent" | "clear" | null> {
|
|
75
|
+
const choices: string[] = [
|
|
76
|
+
"Set for this session (not saved)",
|
|
77
|
+
"Set permanently (saved to config)",
|
|
78
|
+
];
|
|
79
|
+
if (showClear) {
|
|
80
|
+
choices.push("Clear");
|
|
81
|
+
}
|
|
82
|
+
const choice = await ctx.ui.select("Save mode", choices);
|
|
83
|
+
if (choice === undefined) return null;
|
|
84
|
+
if (choice.startsWith("Set for this session")) return "session";
|
|
85
|
+
if (choice.startsWith("Set permanently")) return "permanent";
|
|
86
|
+
return "clear";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Prompt for a model selection and apply it as an override.
|
|
91
|
+
* "(inherits parent)" clears the override (sets to null).
|
|
92
|
+
* The caller is responsible for persistence (saveConfigAtomic).
|
|
93
|
+
*/
|
|
94
|
+
async function applyModelOverride(
|
|
95
|
+
ctx: ExtensionCommandContext,
|
|
96
|
+
modelOptions: string[],
|
|
97
|
+
label: string,
|
|
98
|
+
currentValue: string,
|
|
99
|
+
apply: (chosen: string | null) => void,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const chosen = await promptModelSelection(ctx, modelOptions, currentValue);
|
|
102
|
+
if (chosen === null) return;
|
|
103
|
+
|
|
104
|
+
const effective = chosen === "(inherits parent)" ? null : chosen;
|
|
105
|
+
apply(effective);
|
|
106
|
+
ctx.ui.notify(
|
|
107
|
+
effective === null
|
|
108
|
+
? `${label} inherits parent model`
|
|
109
|
+
: `${label} model set to ${effective}`,
|
|
110
|
+
"info",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Persist concurrency config to disk and apply to the running manager.
|
|
116
|
+
*/
|
|
117
|
+
function applyConcurrencyConfig(): void {
|
|
118
|
+
saveConfigAtomic(__config);
|
|
119
|
+
manager?.setConcurrency(__config.concurrency);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse a concurrency input: prompt, validate (integer ≥ 1), return parsed value or undefined.
|
|
124
|
+
*/
|
|
125
|
+
async function parseConcurrencyInput(
|
|
126
|
+
ctx: ExtensionCommandContext,
|
|
127
|
+
label: string,
|
|
128
|
+
initialValue: string,
|
|
129
|
+
): Promise<number | undefined> {
|
|
130
|
+
const input = await ctx.ui.input(label, initialValue);
|
|
131
|
+
if (input === undefined) return undefined;
|
|
132
|
+
const parsed = parseInt(input.trim(), 10);
|
|
133
|
+
if (isNaN(parsed) || parsed < 1) {
|
|
134
|
+
ctx.ui.notify("Invalid value — must be a number ≥ 1", "error");
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Prompt for a concurrency value, validate, save and apply.
|
|
142
|
+
* Used for editing an existing concurrency limit.
|
|
143
|
+
*/
|
|
144
|
+
async function promptConcurrencyInput(
|
|
145
|
+
ctx: ExtensionCommandContext,
|
|
146
|
+
label: string,
|
|
147
|
+
currentValue: number,
|
|
148
|
+
apply: (value: number) => void,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const parsed = await parseConcurrencyInput(ctx, label, String(currentValue));
|
|
151
|
+
if (parsed === undefined) return;
|
|
152
|
+
apply(parsed);
|
|
153
|
+
applyConcurrencyConfig();
|
|
154
|
+
ctx.ui.notify(
|
|
155
|
+
`${label.replace("Concurrency slots for ", "")} concurrency set to ${parsed}`,
|
|
156
|
+
"info",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Prompt to add a new concurrency limit for a named entity.
|
|
162
|
+
*/
|
|
163
|
+
async function promptAddConcurrencyLimit(
|
|
164
|
+
ctx: ExtensionCommandContext,
|
|
165
|
+
label: string,
|
|
166
|
+
apply: (key: string, value: number) => void,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const parsed = await parseConcurrencyInput(ctx, "Concurrency slots", "1");
|
|
169
|
+
if (parsed === undefined) return;
|
|
170
|
+
apply(label, parsed);
|
|
171
|
+
applyConcurrencyConfig();
|
|
172
|
+
ctx.ui.notify(`${label} concurrency set to ${parsed}`, "info");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Show a select menu once, dispatch the chosen action.
|
|
177
|
+
* Used by the per-agent action sub-menu (single-shot, not a loop).
|
|
178
|
+
*/
|
|
179
|
+
async function runMenu(
|
|
180
|
+
ctx: ExtensionCommandContext,
|
|
181
|
+
title: string,
|
|
182
|
+
items: string[],
|
|
183
|
+
actions: Array<() => Promise<void>>,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const choice = await ctx.ui.select(title, items);
|
|
186
|
+
if (choice === undefined) return;
|
|
187
|
+
const idx = items.indexOf(choice);
|
|
188
|
+
if (idx >= 0 && idx < actions.length) {
|
|
189
|
+
await actions[idx]();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Loop a menu until the user presses Escape or selects "Back".
|
|
195
|
+
* Rebuilds items/actions each iteration so the display stays fresh.
|
|
196
|
+
* Appends blank spacer + "Back" automatically.
|
|
197
|
+
* Used by model settings, concurrency settings, and running agents menus.
|
|
198
|
+
*/
|
|
199
|
+
async function runMenuLoop(
|
|
200
|
+
ctx: ExtensionCommandContext,
|
|
201
|
+
title: string,
|
|
202
|
+
build: () => { items: string[]; actions: Array<() => Promise<void>> },
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
while (true) {
|
|
205
|
+
const { items, actions } = build();
|
|
206
|
+
items.push("");
|
|
207
|
+
actions.push(async () => {});
|
|
208
|
+
items.push("Back");
|
|
209
|
+
actions.push(async () => {});
|
|
210
|
+
|
|
211
|
+
const choice = await ctx.ui.select(title, items);
|
|
212
|
+
if (choice === undefined || choice === "Back") return;
|
|
213
|
+
const idx = items.indexOf(choice);
|
|
214
|
+
if (idx >= 0 && idx < actions.length) {
|
|
215
|
+
await actions[idx]();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ============================================================================
|
|
221
|
+
// /agents command handler
|
|
222
|
+
// ============================================================================
|
|
223
|
+
|
|
224
|
+
export async function showModelSettingsMenu(
|
|
225
|
+
ctx: ExtensionCommandContext,
|
|
226
|
+
modelOptions: string[],
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
return runMenuLoop(ctx, "Model Settings", () => {
|
|
229
|
+
const items: string[] = [];
|
|
230
|
+
const actions: Array<() => Promise<void>> = [];
|
|
231
|
+
|
|
232
|
+
// ── Session overrides section ──
|
|
233
|
+
const hasSessionOverrides = Object.entries(sessionOverrides).some(
|
|
234
|
+
([, v]) => v != null,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const buildOverrideAction = (
|
|
238
|
+
label: string,
|
|
239
|
+
targetKey: string,
|
|
240
|
+
currentValue: string,
|
|
241
|
+
hasPermanentOverride: boolean = false,
|
|
242
|
+
) => async () => {
|
|
243
|
+
const mode = await promptOverrideMode(ctx, hasPermanentOverride);
|
|
244
|
+
if (mode === null) return;
|
|
245
|
+
|
|
246
|
+
// Handle "clear" — remove all overrides (session + config) and save
|
|
247
|
+
if (mode === "clear") {
|
|
248
|
+
delete __config.agent[targetKey];
|
|
249
|
+
if (targetKey !== "default") {
|
|
250
|
+
delete sessionOverrides[targetKey];
|
|
251
|
+
} else {
|
|
252
|
+
sessionOverrides.default = null;
|
|
253
|
+
}
|
|
254
|
+
saveConfigAtomic(__config);
|
|
255
|
+
ctx.ui.notify(`${label} overrides cleared`, "info");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const isSession = mode === "session";
|
|
260
|
+
await applyModelOverride(
|
|
261
|
+
ctx, modelOptions, label,
|
|
262
|
+
currentValue,
|
|
263
|
+
isSession
|
|
264
|
+
? (chosen) => { sessionOverrides[targetKey] = chosen; }
|
|
265
|
+
: (chosen) => {
|
|
266
|
+
__config.agent[targetKey] = chosen;
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
if (!isSession) {
|
|
270
|
+
saveConfigAtomic(__config);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Global default — show session value if present
|
|
275
|
+
const hasSessionGlobal = sessionOverrides.default != null;
|
|
276
|
+
const globalLabel = hasSessionGlobal
|
|
277
|
+
? `Global default model · ${sessionOverrides.default} [session]`
|
|
278
|
+
: __config.agent.default
|
|
279
|
+
? `Global default model · ${__config.agent.default}`
|
|
280
|
+
: "Global default model · (inherits parent)";
|
|
281
|
+
items.push(globalLabel);
|
|
282
|
+
actions.push(buildOverrideAction(
|
|
283
|
+
"Global default", "default",
|
|
284
|
+
hasSessionGlobal
|
|
285
|
+
? sessionOverrides.default!
|
|
286
|
+
: __config.agent.default ?? "(inherits parent)",
|
|
287
|
+
));
|
|
288
|
+
|
|
289
|
+
// Force background toggle
|
|
290
|
+
const forceBgLabel = __config.agent.forceBackground
|
|
291
|
+
? "Force background · ON"
|
|
292
|
+
: "Force background · OFF";
|
|
293
|
+
items.push(forceBgLabel);
|
|
294
|
+
actions.push(async () => {
|
|
295
|
+
__config.agent.forceBackground = !__config.agent.forceBackground;
|
|
296
|
+
saveConfigAtomic(__config);
|
|
297
|
+
ctx.ui.notify(
|
|
298
|
+
`Force background ${__config.agent.forceBackground ? "ON" : "OFF"}`,
|
|
299
|
+
"info",
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
items.push("");
|
|
304
|
+
actions.push(async () => {});
|
|
305
|
+
items.push("─── per-type overrides ───");
|
|
306
|
+
actions.push(async () => {}); // separator
|
|
307
|
+
|
|
308
|
+
// Per-type overrides — show only types with an explicit override (session or config)
|
|
309
|
+
// All others inherit the global default; accessible via "Override another type..."
|
|
310
|
+
const types = getAllTypes();
|
|
311
|
+
const typeEntries = types.map((typeName) => {
|
|
312
|
+
const cfg = getAgentConfig(typeName);
|
|
313
|
+
const sessionOverride = sessionOverrides[typeName];
|
|
314
|
+
const configOverride = __config.agent[typeName];
|
|
315
|
+
const hasSession = sessionOverride != null;
|
|
316
|
+
const hasConfigOverride = configOverride != null && typeof configOverride === "string";
|
|
317
|
+
const effectiveModel = resolveModel({
|
|
318
|
+
subagentType: typeName,
|
|
319
|
+
agentConfig: cfg,
|
|
320
|
+
config: __config,
|
|
321
|
+
parentModelId: "(inherits parent)",
|
|
322
|
+
sessionOverrides,
|
|
323
|
+
});
|
|
324
|
+
return { typeName, cfg, sessionOverride, configOverride, hasSession, hasConfigOverride, effectiveModel };
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const overridden = typeEntries.filter(e => e.hasSession || e.hasConfigOverride);
|
|
328
|
+
const nonOverridden = typeEntries.filter(e => !e.hasSession && !e.hasConfigOverride);
|
|
329
|
+
|
|
330
|
+
if (overridden.length === 0) {
|
|
331
|
+
items.push(" (all inherit global default)");
|
|
332
|
+
actions.push(async () => {}); // no-op
|
|
333
|
+
} else {
|
|
334
|
+
overridden.sort((a, b) => a.effectiveModel.localeCompare(b.effectiveModel));
|
|
335
|
+
const padLen = Math.max(...types.map(t => t.length));
|
|
336
|
+
for (const { typeName, cfg, sessionOverride, configOverride, hasSession, effectiveModel } of overridden) {
|
|
337
|
+
const frontmatterHint = !hasSession && configOverride && cfg?.model ? `${cfg.model} → ` : "";
|
|
338
|
+
const displayModel = hasSession ? `${sessionOverride} [session]` : effectiveModel;
|
|
339
|
+
items.push(`${typeName.padEnd(padLen)} · ${frontmatterHint}${displayModel}`);
|
|
340
|
+
|
|
341
|
+
const currentValue = hasSession ? sessionOverride! : effectiveModel;
|
|
342
|
+
actions.push(buildOverrideAction(typeName, typeName, currentValue, !!configOverride));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Add override for a type that currently inherits
|
|
347
|
+
if (nonOverridden.length > 0) {
|
|
348
|
+
items.push("Override another type...");
|
|
349
|
+
actions.push(async () => {
|
|
350
|
+
const typeNames = nonOverridden.map(e => e.typeName);
|
|
351
|
+
const chosen = await ctx.ui.select("Select agent type", typeNames);
|
|
352
|
+
if (chosen === undefined) return;
|
|
353
|
+
const entry = nonOverridden.find(e => e.typeName === chosen)!;
|
|
354
|
+
const action = buildOverrideAction(chosen, chosen, entry.effectiveModel, false);
|
|
355
|
+
await action();
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Clear session overrides
|
|
360
|
+
if (hasSessionOverrides) {
|
|
361
|
+
items.push("Clear session overrides");
|
|
362
|
+
actions.push(async () => {
|
|
363
|
+
sessionOverrides.default = null;
|
|
364
|
+
for (const key of Object.keys(sessionOverrides)) {
|
|
365
|
+
if (key !== "default") {
|
|
366
|
+
delete sessionOverrides[key];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
ctx.ui.notify("Session overrides cleared", "info");
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Clear all overrides
|
|
374
|
+
items.push("Clear all overrides");
|
|
375
|
+
actions.push(async () => {
|
|
376
|
+
const hasOverrides = Object.entries(__config.agent).some(
|
|
377
|
+
([k, v]) => k !== "default" && k !== "forceBackground" && v != null,
|
|
378
|
+
);
|
|
379
|
+
if (!hasOverrides && __config.agent.default === null) {
|
|
380
|
+
ctx.ui.notify("No overrides to clear", "info");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
__config.agent = { default: __config.agent.default, forceBackground: __config.agent.forceBackground };
|
|
384
|
+
saveConfigAtomic(__config);
|
|
385
|
+
ctx.ui.notify("All model overrides cleared", "info");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return { items, actions };
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function showAgentsMainMenu(
|
|
393
|
+
ctx: ExtensionCommandContext,
|
|
394
|
+
modelOptions: string[],
|
|
395
|
+
): Promise<void> {
|
|
396
|
+
const menuItems = [
|
|
397
|
+
"1. Model settings — Set global default and per-type model overrides",
|
|
398
|
+
"2. Concurrency settings — Set per-model slot limits",
|
|
399
|
+
"3. Running agents — List running/queued agents",
|
|
400
|
+
"4. Debug — Agent types, briefing, diagnostics",
|
|
401
|
+
"",
|
|
402
|
+
"Press Escape to close",
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
// Loop so sub-menus navigate back to root; only Escape at root closes
|
|
406
|
+
while (true) {
|
|
407
|
+
const choice = await ctx.ui.select("Subagents Management", menuItems);
|
|
408
|
+
if (choice === undefined || choice === "Press Escape to close") return;
|
|
409
|
+
|
|
410
|
+
if (choice.startsWith("1.")) {
|
|
411
|
+
await showModelSettingsMenu(ctx, modelOptions);
|
|
412
|
+
} else if (choice.startsWith("2.")) {
|
|
413
|
+
await showConcurrencySettingsMenu(ctx, modelOptions);
|
|
414
|
+
} else if (choice.startsWith("3.")) {
|
|
415
|
+
await showRunningAgentsMenu(ctx);
|
|
416
|
+
} else if (choice.startsWith("4.")) {
|
|
417
|
+
await showDebugMenu(ctx);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function showDebugMenu(ctx: ExtensionCommandContext): Promise<void> {
|
|
423
|
+
const menuItems = [
|
|
424
|
+
"1. Agent types — List available agent types and their configs",
|
|
425
|
+
"2. Agent briefing — Send agent types/capabilities info to LLM (Optional, if having issues)",
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
while (true) {
|
|
429
|
+
const choice = await ctx.ui.select("Debug", menuItems);
|
|
430
|
+
if (choice === undefined) return;
|
|
431
|
+
|
|
432
|
+
if (choice.startsWith("1.")) {
|
|
433
|
+
await showAgentTypes(ctx);
|
|
434
|
+
} else if (choice.startsWith("2.")) {
|
|
435
|
+
await handleAgentBriefing(ctx);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void> {
|
|
441
|
+
const types = getAvailableTypes();
|
|
442
|
+
const agents = types.map((t) => ({ name: t, config: getAgentConfig(t) }));
|
|
443
|
+
|
|
444
|
+
const lines: string[] = [
|
|
445
|
+
"# Agent Types and Capabilities\n",
|
|
446
|
+
"The following agent types are available. Use the `agent` parameter to select one.\n",
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
for (const { name, config } of agents) {
|
|
450
|
+
if (!config) continue;
|
|
451
|
+
lines.push(`## ${config.displayName ?? name}`);
|
|
452
|
+
lines.push(config.description);
|
|
453
|
+
lines.push("");
|
|
454
|
+
|
|
455
|
+
if (config.builtinToolNames) {
|
|
456
|
+
lines.push(`**Tools:** ${config.builtinToolNames.join(", ")}`);
|
|
457
|
+
}
|
|
458
|
+
if (config.model) {
|
|
459
|
+
lines.push(`**Default model:** ${config.model}`);
|
|
460
|
+
}
|
|
461
|
+
if (config.maxTurns) {
|
|
462
|
+
lines.push(`**Max turns:** ${config.maxTurns}`);
|
|
463
|
+
}
|
|
464
|
+
lines.push("");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Parameter descriptions
|
|
468
|
+
lines.push("## Agent Tool Parameters\n");
|
|
469
|
+
lines.push("| Parameter | Description |");
|
|
470
|
+
lines.push("|-----------|-------------|");
|
|
471
|
+
lines.push("| `prompt` | The task for the agent (required) |");
|
|
472
|
+
lines.push("| `description` | One-line summary of what the agent should do (required) |");
|
|
473
|
+
lines.push("| `agent` | Which agent type to use (default: general-purpose) |");
|
|
474
|
+
lines.push("| `thinking` | Optional thinking mode override (e.g., `off`, `minimal`, `low`, `medium`, `high`, `xhigh`) |");
|
|
475
|
+
lines.push("| `run_in_background` | When `true`, result is auto-delivered — do NOT poll. Continue working while waiting. |");
|
|
476
|
+
lines.push("| `resume` | Agent ID to resume from; when set, `prompt` is appended to the previous conversation |");
|
|
477
|
+
lines.push("");
|
|
478
|
+
|
|
479
|
+
// Usage guidelines
|
|
480
|
+
lines.push("## Usage Guidelines\n");
|
|
481
|
+
lines.push("- Agents start fresh with their config — they do NOT inherit the parent conversation");
|
|
482
|
+
lines.push("- For parallel tasks, spawn multiple `run_in_background: true` agents in one turn");
|
|
483
|
+
lines.push(" → Results are auto-delivered — do NOT poll, the result will arrive when ready");
|
|
484
|
+
lines.push("- Use `resume` to continue an incomplete agent's conversation");
|
|
485
|
+
piInstance.sendUserMessage(lines.join("\n"));
|
|
486
|
+
ctx.ui.notify("Agent briefing sent to LLM", "info");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Build a sub-menu for a single per-provider or per-model entry:
|
|
491
|
+
* "Edit limit" to change the value, or "Remove limit" to delete it.
|
|
492
|
+
*/
|
|
493
|
+
async function editOrRemoveConcurrencyEntry(
|
|
494
|
+
ctx: ExtensionCommandContext,
|
|
495
|
+
label: string,
|
|
496
|
+
entityType: "provider" | "model",
|
|
497
|
+
entityKey: string,
|
|
498
|
+
currentValue: number,
|
|
499
|
+
applyUpdate: (value: number) => void,
|
|
500
|
+
applyRemove: () => void,
|
|
501
|
+
): Promise<void> {
|
|
502
|
+
await runMenu(ctx, `${entityKey} concurrency`, [
|
|
503
|
+
"Edit limit",
|
|
504
|
+
"Remove limit",
|
|
505
|
+
], [
|
|
506
|
+
async () => {
|
|
507
|
+
await promptConcurrencyInput(
|
|
508
|
+
ctx, label, currentValue,
|
|
509
|
+
applyUpdate,
|
|
510
|
+
);
|
|
511
|
+
},
|
|
512
|
+
async () => {
|
|
513
|
+
applyRemove();
|
|
514
|
+
applyConcurrencyConfig();
|
|
515
|
+
ctx.ui.notify(
|
|
516
|
+
`Removed per-${entityType} limit for ${entityKey}`,
|
|
517
|
+
"info",
|
|
518
|
+
);
|
|
519
|
+
},
|
|
520
|
+
]);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export async function showConcurrencySettingsMenu(
|
|
524
|
+
ctx: ExtensionCommandContext,
|
|
525
|
+
modelOptions: string[],
|
|
526
|
+
): Promise<void> {
|
|
527
|
+
const providers = [...new Set(modelOptions.map((m) => m.split("/")[0]))].sort();
|
|
528
|
+
|
|
529
|
+
return runMenuLoop(ctx, "Concurrency Settings", () => {
|
|
530
|
+
const items: string[] = [];
|
|
531
|
+
const actions: Array<() => Promise<void>> = [];
|
|
532
|
+
|
|
533
|
+
// Global default
|
|
534
|
+
items.push(`Default concurrency limit · ${__config.concurrency.default}`);
|
|
535
|
+
actions.push(async () => {
|
|
536
|
+
await promptConcurrencyInput(
|
|
537
|
+
ctx, "Default concurrency limit", __config.concurrency.default,
|
|
538
|
+
(value) => { __config.concurrency.default = value; },
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// Reset all to defaults
|
|
543
|
+
items.push("Reset all to defaults");
|
|
544
|
+
actions.push(async () => {
|
|
545
|
+
__config.concurrency = { default: 4 };
|
|
546
|
+
applyConcurrencyConfig();
|
|
547
|
+
ctx.ui.notify("Concurrency reset to defaults", "info");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// ── Per-provider limits ──
|
|
551
|
+
const providerLimits = __config.concurrency.providers ?? {};
|
|
552
|
+
const configuredProviders = Object.keys(providerLimits);
|
|
553
|
+
if (configuredProviders.length > 0) {
|
|
554
|
+
items.push("");
|
|
555
|
+
actions.push(async () => {});
|
|
556
|
+
items.push("─── per-provider limits ───");
|
|
557
|
+
actions.push(async () => {}); // separator
|
|
558
|
+
|
|
559
|
+
for (const provider of configuredProviders) {
|
|
560
|
+
const limit = providerLimits[provider];
|
|
561
|
+
items.push(`${provider} · ${limit} slots`);
|
|
562
|
+
actions.push(async () => {
|
|
563
|
+
await editOrRemoveConcurrencyEntry(
|
|
564
|
+
ctx,
|
|
565
|
+
`Concurrency slots for ${provider}`,
|
|
566
|
+
"provider",
|
|
567
|
+
provider,
|
|
568
|
+
limit,
|
|
569
|
+
(value) => {
|
|
570
|
+
const current = __config.concurrency.providers ?? {};
|
|
571
|
+
__config.concurrency.providers = { ...current, [provider]: value };
|
|
572
|
+
},
|
|
573
|
+
() => {
|
|
574
|
+
const providers = __config.concurrency.providers;
|
|
575
|
+
if (providers) {
|
|
576
|
+
delete providers[provider];
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Add per-provider limit
|
|
585
|
+
items.push("Add per-provider limit...");
|
|
586
|
+
actions.push(async () => {
|
|
587
|
+
const provider = await ctx.ui.select("Select provider", providers);
|
|
588
|
+
if (provider === undefined) return;
|
|
589
|
+
await promptAddConcurrencyLimit(
|
|
590
|
+
ctx, provider,
|
|
591
|
+
(key, value) => {
|
|
592
|
+
const current = __config.concurrency.providers ?? {};
|
|
593
|
+
__config.concurrency.providers = { ...current, [key]: value };
|
|
594
|
+
},
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// ── Per-model limits ──
|
|
599
|
+
const models = __config.concurrency.models ?? {};
|
|
600
|
+
const modelKeys = Object.keys(models);
|
|
601
|
+
if (modelKeys.length > 0) {
|
|
602
|
+
items.push("");
|
|
603
|
+
actions.push(async () => {});
|
|
604
|
+
items.push("─── per-model limits ───");
|
|
605
|
+
actions.push(async () => {}); // separator
|
|
606
|
+
|
|
607
|
+
for (const modelKey of modelKeys) {
|
|
608
|
+
const limit = models[modelKey];
|
|
609
|
+
items.push(`${modelKey} · ${limit} slots`);
|
|
610
|
+
actions.push(async () => {
|
|
611
|
+
await editOrRemoveConcurrencyEntry(
|
|
612
|
+
ctx,
|
|
613
|
+
`Concurrency slots for ${modelKey}`,
|
|
614
|
+
"model",
|
|
615
|
+
modelKey,
|
|
616
|
+
limit,
|
|
617
|
+
(value) => {
|
|
618
|
+
const current = __config.concurrency.models ?? {};
|
|
619
|
+
__config.concurrency.models = { ...current, [modelKey]: value };
|
|
620
|
+
},
|
|
621
|
+
() => {
|
|
622
|
+
const models = __config.concurrency.models;
|
|
623
|
+
if (models) {
|
|
624
|
+
delete models[modelKey];
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
);
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Add per-model limit
|
|
633
|
+
items.push("Add per-model limit...");
|
|
634
|
+
actions.push(async () => {
|
|
635
|
+
const modelKey = await promptModelSelection(
|
|
636
|
+
ctx, modelOptions, __config.agent.default ?? "(inherits parent)",
|
|
637
|
+
);
|
|
638
|
+
if (modelKey === null) return;
|
|
639
|
+
await promptAddConcurrencyLimit(
|
|
640
|
+
ctx, modelKey.trim(),
|
|
641
|
+
(key, value) => {
|
|
642
|
+
const current = __config.concurrency.models ?? {};
|
|
643
|
+
__config.concurrency.models = { ...current, [key]: value };
|
|
644
|
+
},
|
|
645
|
+
);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return { items, actions };
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function showRunningAgentsMenu(
|
|
653
|
+
ctx: ExtensionCommandContext,
|
|
654
|
+
): Promise<void> {
|
|
655
|
+
const records = manager?.listAgents() ?? [];
|
|
656
|
+
if (records.length === 0) {
|
|
657
|
+
ctx.ui.notify("No agents have been spawned this session", "info");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return runMenuLoop(ctx, "Running Agents", () => {
|
|
662
|
+
const records = manager?.listAgents() ?? [];
|
|
663
|
+
const running = records.filter((r) => r.status === "running" || r.status === "queued");
|
|
664
|
+
|
|
665
|
+
const items: string[] = [];
|
|
666
|
+
const actions: Array<() => Promise<void>> = [];
|
|
667
|
+
|
|
668
|
+
for (const record of records) {
|
|
669
|
+
const elapsed = Math.round((Date.now() - record.startedAt) / 1000);
|
|
670
|
+
const statusIcon = record.status === "running" ? "▶" :
|
|
671
|
+
record.status === "completed" ? "✓" :
|
|
672
|
+
record.status === "queued" ? "⏳" :
|
|
673
|
+
record.status === "error" ? "✗" : "•";
|
|
674
|
+
items.push(
|
|
675
|
+
`${statusIcon} ${record.id.slice(0, 8)} ${record.type} ${record.status} ${elapsed}s`,
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
actions.push(async () => {
|
|
679
|
+
await showAgentActions(ctx, record);
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (running.length > 0) {
|
|
684
|
+
items.push("");
|
|
685
|
+
actions.push(async () => {});
|
|
686
|
+
items.push("─── actions ───");
|
|
687
|
+
actions.push(async () => {}); // separator
|
|
688
|
+
|
|
689
|
+
items.push(`Stop ${running.length} running agent(s)`);
|
|
690
|
+
actions.push(async () => {
|
|
691
|
+
for (const record of running) {
|
|
692
|
+
manager?.abort(record.id);
|
|
693
|
+
}
|
|
694
|
+
ctx.ui.notify(`Stopped ${running.length} agent(s)`, "info");
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return { items, actions };
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Show a ResultViewer for an agent's result, error, or snapshot.
|
|
704
|
+
* @param kind — "result", "error", or "snapshot" — used for the title suffix
|
|
705
|
+
*/
|
|
706
|
+
async function showResultViewer(
|
|
707
|
+
ctx: ExtensionCommandContext,
|
|
708
|
+
record: AgentRecord,
|
|
709
|
+
kind: "result" | "error" | "snapshot",
|
|
710
|
+
text: string,
|
|
711
|
+
): Promise<void> {
|
|
712
|
+
const titleSuffix = kind === "result"
|
|
713
|
+
? record.id.slice(0, 8)
|
|
714
|
+
: kind === "snapshot"
|
|
715
|
+
? `snapshot \u00b7 ${record.id.slice(0, 8)}`
|
|
716
|
+
: "Error";
|
|
717
|
+
const stats: ResultViewerStats = {
|
|
718
|
+
lifetimeUsage: record.lifetimeUsage,
|
|
719
|
+
turnCount: record.turnCount,
|
|
720
|
+
durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
|
|
721
|
+
};
|
|
722
|
+
const refreshCallback =
|
|
723
|
+
kind === "snapshot" && record.session
|
|
724
|
+
? () => buildSnapshotMarkdown(record.session!.messages)
|
|
725
|
+
: undefined;
|
|
726
|
+
|
|
727
|
+
await ctx.ui.custom<void>(
|
|
728
|
+
(tui, theme, _kb, done) =>
|
|
729
|
+
new ResultViewer(
|
|
730
|
+
`${getDisplayName(record.type)} · ${titleSuffix}`,
|
|
731
|
+
text,
|
|
732
|
+
{ onClose: () => done(), onRefresh: refreshCallback },
|
|
733
|
+
theme,
|
|
734
|
+
tui.terminal.rows,
|
|
735
|
+
stats,
|
|
736
|
+
),
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Send a steer message to a specific agent. Used by the per-agent action menu.
|
|
742
|
+
*/
|
|
743
|
+
async function steerAgentById(
|
|
744
|
+
agentId: string,
|
|
745
|
+
ctx: ExtensionCommandContext,
|
|
746
|
+
): Promise<void> {
|
|
747
|
+
const record = manager?.getRecord(agentId);
|
|
748
|
+
if (!record) {
|
|
749
|
+
ctx.ui.notify("Agent not found", "error");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const message = await ctx.ui.input(`Steer ${record.type}`);
|
|
754
|
+
if (!message?.trim()) return;
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
if (!record.session) {
|
|
758
|
+
if (!record.pendingSteers) {
|
|
759
|
+
record.pendingSteers = [];
|
|
760
|
+
}
|
|
761
|
+
record.pendingSteers.push(message.trim());
|
|
762
|
+
ctx.ui.notify(`Steer message queued for ${record.id.slice(0, 8)}…`, "info");
|
|
763
|
+
} else {
|
|
764
|
+
await record.session.steer(message.trim());
|
|
765
|
+
ctx.ui.notify(`Steer sent to ${record.id.slice(0, 8)}…`, "info");
|
|
766
|
+
}
|
|
767
|
+
} catch (err) {
|
|
768
|
+
ctx.ui.notify(`Steer failed: ${errorMessage(err)}`, "error");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Sub-menu with actions for a single agent. Replaces the old showAgentDetail
|
|
774
|
+
* notify popup — clicking an agent in the running agents menu opens actions.
|
|
775
|
+
*/
|
|
776
|
+
export async function showAgentActions(
|
|
777
|
+
ctx: ExtensionCommandContext,
|
|
778
|
+
record: AgentRecord,
|
|
779
|
+
): Promise<void> {
|
|
780
|
+
const items: string[] = [];
|
|
781
|
+
const actions: Array<() => Promise<void>> = [];
|
|
782
|
+
|
|
783
|
+
const isRunning = record.status === "running" || record.status === "queued";
|
|
784
|
+
const hasSession = !!record.session;
|
|
785
|
+
const hasResult = !!record.result && record.result.length > 0;
|
|
786
|
+
const hasError = !!record.error && record.error.length > 0;
|
|
787
|
+
|
|
788
|
+
// View actions first
|
|
789
|
+
if (record.status === "running" && hasSession) {
|
|
790
|
+
items.push("View snapshot");
|
|
791
|
+
actions.push(async () => {
|
|
792
|
+
const messages = record.session!.messages;
|
|
793
|
+
const markdown = buildSnapshotMarkdown(messages);
|
|
794
|
+
await showResultViewer(ctx, record, "snapshot", markdown);
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (hasResult) {
|
|
799
|
+
items.push("View result");
|
|
800
|
+
actions.push(async () => {
|
|
801
|
+
await showResultViewer(ctx, record, "result", record.result!);
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (hasError) {
|
|
806
|
+
items.push("View error");
|
|
807
|
+
actions.push(async () => {
|
|
808
|
+
await showResultViewer(ctx, record, "error", record.error!);
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Then control actions
|
|
813
|
+
if (isRunning) {
|
|
814
|
+
items.push("Steer");
|
|
815
|
+
actions.push(async () => {
|
|
816
|
+
await steerAgentById(record.id, ctx);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
items.push("Stop");
|
|
820
|
+
actions.push(async () => {
|
|
821
|
+
manager?.abort(record.id);
|
|
822
|
+
ctx.ui.notify(`Stopped ${record.id.slice(0, 8)}`, "info");
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (items.length === 0) {
|
|
827
|
+
ctx.ui.notify(`Agent ${record.id.slice(0, 8)} — no actions available`, "info");
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Append blank spacer + "Back" as the last items
|
|
832
|
+
items.push("");
|
|
833
|
+
actions.push(async () => {});
|
|
834
|
+
items.push("Back");
|
|
835
|
+
actions.push(async () => {});
|
|
836
|
+
|
|
837
|
+
await runMenu(ctx, `Agent ${record.id.slice(0, 8)}`, items, actions);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function showAgentTypes(ctx: ExtensionCommandContext): Promise<void> {
|
|
841
|
+
const types = getAllTypes();
|
|
842
|
+
if (types.length === 0) {
|
|
843
|
+
ctx.ui.notify("No agent types available", "info");
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const lines: string[] = ["Available agent types:\n"];
|
|
848
|
+
for (const name of types) {
|
|
849
|
+
const cfg = getAgentConfig(name);
|
|
850
|
+
if (!cfg) continue;
|
|
851
|
+
const disabled = cfg.enabled === false ? " [DISABLED]" : "";
|
|
852
|
+
const model = cfg.model ? ` Model: ${cfg.model}` : "";
|
|
853
|
+
const tools = cfg.builtinToolNames
|
|
854
|
+
? ` Tools: ${cfg.builtinToolNames.join(", ")}`
|
|
855
|
+
: " Tools: all built-in tools";
|
|
856
|
+
const source = cfg.source ? ` Source: ${cfg.source}` : "";
|
|
857
|
+
lines.push(` ${name}${disabled}`);
|
|
858
|
+
lines.push(` ${cfg.description}`);
|
|
859
|
+
if (model) lines.push(model);
|
|
860
|
+
lines.push(tools);
|
|
861
|
+
if (source) lines.push(source);
|
|
862
|
+
lines.push("");
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
866
|
+
}
|