@ferris1225/pi-subagents 1.0.0 → 2.0.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/src/setup.ts CHANGED
@@ -1,639 +1,437 @@
1
- /**
2
- * Interactive configuration wizard for /subagents-setup.
3
- *
4
- * Everything is selection-driven (no free-text answers). Enabled agents share a
5
- * compact primary/backup model-pool editor backed by one searchable model list;
6
- * the remaining settings use small selection menus. Config is written to
7
- * <agentDir>/pi-subagents.json.
8
- */
9
-
10
- import { stat } from "node:fs/promises";
11
- import type { ExtensionCommandContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
12
- import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
13
- import {
14
- AGENT_SCOPE_VALUES,
15
- BUILTIN_AGENT_NAMES,
16
- DEFAULT_CONFIG,
17
- DEFAULT_ENABLED_AGENTS,
18
- DEFAULT_IDLE_TIMEOUT_SEC,
19
- DEFAULT_MAX_CONCURRENCY,
20
- DEFAULT_MAX_FIX_ROUNDS,
21
- THINKING_LEVEL_VALUES,
22
- type AgentScope,
23
- type SubagentsConfig,
24
- type ThinkingLevel,
25
- errorMessage,
26
- getConfigPath,
27
- loadConfig,
28
- saveConfig,
29
- } from "./config.ts";
30
- import {
31
- CURRENT_MAIN_MODEL,
32
- applyModelPoolChoice,
33
- availableModelsInScope,
34
- buildAgentModelPoolRows,
35
- buildModelPickerItems,
36
- currentModelRef,
37
- modelRef,
38
- type AgentModelPoolMaps,
39
- type ModelPickerSlot,
40
- type ModelPoolSlot,
41
- } from "./models.ts";
42
- import { promptSelectMany, promptSelectOne } from "./ui.ts";
43
- import { loadBuiltinAgents } from "./agents.ts";
44
-
45
- const INHERIT = "__inherit__";
46
- const MODEL_POOL_WIDE_MIN = 96;
47
-
48
- /** Effective per-agent default strength from builtin frontmatter (config overrides win at spawn). */
49
- function builtinThinkingDefaults(): Map<string, ThinkingLevel> {
50
- const map = new Map<string, ThinkingLevel>();
51
- for (const agent of loadBuiltinAgents()) {
52
- if (agent.thinking) map.set(agent.name, agent.thinking);
53
- }
54
- return map;
55
- }
56
-
57
- /** Short, selection-friendly descriptions for the built-in agents. */
58
- const MODULE_HINTS: Record<string, string> = {
59
- explore: "read-only codebase recon (fast model)",
60
- worker: "implement / fix / refactor / test (full tools)",
61
- reviewer: "adversarial pre-commit review (read-only)",
62
- };
63
-
64
- function moduleLabel(name: string): string {
65
- const hint = MODULE_HINTS[name];
66
- return hint ? `${name} — ${hint}` : name;
67
- }
68
-
69
- async function configExists(configPath: string): Promise<boolean> {
70
- try {
71
- await stat(configPath);
72
- return true;
73
- } catch {
74
- return false;
75
- }
76
- }
77
-
78
- async function pickEnabledAgents(
79
- ctx: ExtensionCommandContext,
80
- current: readonly string[],
81
- ): Promise<string[] | undefined> {
82
- const items = BUILTIN_AGENT_NAMES.map((name) => ({ value: name, label: moduleLabel(name) }));
83
- return promptSelectMany(
84
- ctx,
85
- "Enable which sub-agents?",
86
- "Space toggles Enter confirms Esc cancels",
87
- items,
88
- current,
89
- );
90
- }
91
-
92
- type ModelPoolEditorResult =
93
- | { action: "edit"; agentName: string; slot: ModelPoolSlot }
94
- | { action: "save" };
95
-
96
- interface ModelPoolEditorStyles {
97
- border(text: string): string;
98
- title(text: string): string;
99
- dim(text: string): string;
100
- accent(text: string): string;
101
- selected(text: string): string;
102
- }
103
-
104
- /** Compact overview: every enabled agent shows Primary and Backup on one row. */
105
- class ModelPoolEditor implements Component {
106
- private row = 0;
107
- private slot: ModelPoolSlot = "primary";
108
-
109
- constructor(
110
- private readonly agentNames: readonly string[],
111
- private readonly pools: AgentModelPoolMaps,
112
- private readonly styles: ModelPoolEditorStyles,
113
- private readonly tui: TUI,
114
- private readonly keybindings: KeybindingsManager,
115
- private readonly done: (result: ModelPoolEditorResult | undefined) => void,
116
- initialCell?: { agentName: string; slot: ModelPoolSlot },
117
- ) {
118
- const initialRow = initialCell ? agentNames.indexOf(initialCell.agentName) : -1;
119
- if (initialCell && initialRow >= 0) {
120
- this.row = initialRow;
121
- this.slot = initialCell.slot;
122
- }
123
- }
124
-
125
- render(width: number): string[] {
126
- const fit = (line: string): string => truncateToWidth(line, width, "");
127
- const border = this.styles.border("─".repeat(Math.max(1, width)));
128
- const rows = buildAgentModelPoolRows(this.agentNames, this.pools);
129
- const lines = [
130
- fit(border),
131
- fit(this.styles.title("Agent model pools")),
132
- fit(this.styles.dim("↑/↓ agent • ←/→ Primary/Backup • Enter edit/save • Esc cancel")),
133
- fit(border),
134
- ];
135
- for (let index = 0; index < rows.length; index++) {
136
- const pool = rows[index];
137
- const active = this.row === index;
138
- const mark = active ? this.styles.accent("❯ ") : " ";
139
- const primary = active && this.slot === "primary"
140
- ? this.styles.selected(`[Primary: ${pool.primary}]`)
141
- : `Primary: ${pool.primary}`;
142
- const backup = active && this.slot === "backup"
143
- ? this.styles.selected(`[Backup: ${pool.backup}]`)
144
- : `Backup: ${pool.backup}`;
145
- if (width >= MODEL_POOL_WIDE_MIN) {
146
- lines.push(fit(`${mark}${this.styles.accent(pool.name)} · ${primary} · ${backup}`));
147
- } else {
148
- // Keep both cells visible on narrow terminals; a long primary can no
149
- // longer push Backup (including its selected state) off-screen.
150
- lines.push(fit(`${mark}${this.styles.accent(pool.name)}`));
151
- lines.push(fit(` ${primary}`));
152
- lines.push(fit(` ${backup}`));
153
- }
154
- }
155
- const saveActive = this.row === rows.length;
156
- lines.push(fit(`${saveActive ? this.styles.accent("❯ ") : " "}${saveActive ? this.styles.selected("Save model pools and continue") : "Save model pools and continue"}`));
157
- lines.push(fit(border));
158
- return lines;
159
- }
160
-
161
- handleInput(data: string): void {
162
- const lastRow = this.agentNames.length;
163
- if (this.keybindings.matches(data, "tui.select.up")) {
164
- this.row = this.row === 0 ? lastRow : this.row - 1;
165
- } else if (this.keybindings.matches(data, "tui.select.down")) {
166
- this.row = this.row === lastRow ? 0 : this.row + 1;
167
- } else if (
168
- this.row < lastRow &&
169
- (this.keybindings.matches(data, "tui.editor.cursorLeft") ||
170
- this.keybindings.matches(data, "tui.editor.cursorRight"))
171
- ) {
172
- this.slot = this.slot === "primary" ? "backup" : "primary";
173
- } else if (this.keybindings.matches(data, "tui.select.confirm")) {
174
- if (this.row === lastRow) this.done({ action: "save" });
175
- else this.done({ action: "edit", agentName: this.agentNames[this.row], slot: this.slot });
176
- return;
177
- } else if (this.keybindings.matches(data, "tui.select.cancel")) {
178
- this.done(undefined);
179
- return;
180
- }
181
- this.tui.requestRender();
182
- }
183
-
184
- invalidate(): void {}
185
- }
186
-
187
- async function promptModelPoolOverview(
188
- ctx: ExtensionCommandContext,
189
- agentNames: readonly string[],
190
- pools: AgentModelPoolMaps,
191
- initialCell?: { agentName: string; slot: ModelPoolSlot },
192
- ): Promise<ModelPoolEditorResult | undefined> {
193
- return ctx.ui.custom<ModelPoolEditorResult | undefined>((tui, theme, keybindings, done) =>
194
- new ModelPoolEditor(
195
- agentNames,
196
- pools,
197
- {
198
- border: (text) => theme.fg("accent", text),
199
- title: (text) => theme.fg("accent", theme.bold(text)),
200
- dim: (text) => theme.fg("dim", text),
201
- accent: (text) => theme.fg("accent", text),
202
- selected: (text) => theme.fg("accent", theme.bold(text)),
203
- },
204
- tui,
205
- keybindings,
206
- done,
207
- initialCell,
208
- ),
209
- );
210
- }
211
-
212
- async function pickConfiguredModel(
213
- ctx: ExtensionCommandContext,
214
- title: string,
215
- slot: ModelPickerSlot,
216
- configuredRef: string | undefined,
217
- escNote: string,
218
- ): Promise<string | undefined> {
219
- const models = availableModelsInScope(ctx);
220
- const items = buildModelPickerItems({
221
- models,
222
- availableRefs: models.map(modelRef),
223
- slot,
224
- configuredRef,
225
- mainRef: currentModelRef(ctx),
226
- });
227
- return promptSelectOne(
228
- ctx,
229
- title,
230
- `Type to filter by provider, model, capability, or availability • ↑/↓ • Enter selects • Esc ${escNote}`,
231
- items,
232
- configuredRef ?? CURRENT_MAIN_MODEL,
233
- );
234
- }
235
-
236
- /** Shared by full setup and configure-one-agent. Esc in a model list returns to
237
- * the overview; Esc in the overview cancels the whole pool edit. */
238
- async function editModelPools(
239
- ctx: ExtensionCommandContext,
240
- agentNames: readonly string[],
241
- initial: AgentModelPoolMaps,
242
- ): Promise<AgentModelPoolMaps | undefined> {
243
- let pools: AgentModelPoolMaps = {
244
- agentModels: { ...initial.agentModels },
245
- agentBackupModels: { ...initial.agentBackupModels },
246
- };
247
- let activeCell: { agentName: string; slot: ModelPoolSlot } | undefined;
248
- while (true) {
249
- const action = await promptModelPoolOverview(ctx, agentNames, pools, activeCell);
250
- if (action === undefined) return undefined;
251
- if (action.action === "save") return pools;
252
- activeCell = { agentName: action.agentName, slot: action.slot };
253
- const current = action.slot === "primary"
254
- ? pools.agentModels[action.agentName]
255
- : pools.agentBackupModels[action.agentName];
256
- const choice = await pickConfiguredModel(
257
- ctx,
258
- `${action.slot === "primary" ? "Primary" : "Backup"} model for "${action.agentName}"`,
259
- action.slot,
260
- current,
261
- "returns to model pools",
262
- );
263
- if (choice !== undefined) pools = applyModelPoolChoice(pools, action.agentName, action.slot, choice);
264
- }
265
- }
266
-
267
- async function pickVisionModel(
268
- ctx: ExtensionCommandContext,
269
- currentRef: string | undefined,
270
- ): Promise<string | undefined> {
271
- return pickConfiguredModel(
272
- ctx,
273
- "Vision primary for image tasks (then agent backup → current main model)",
274
- "vision",
275
- currentRef,
276
- "cancels setup",
277
- );
278
- }
279
-
280
- const THINKING_LEVEL_HINTS: Record<ThinkingLevel, string> = {
281
- off: "no reasoning tokens (fastest)",
282
- minimal: "minimal reasoning",
283
- low: "light reasoning",
284
- medium: "balanced reasoning",
285
- high: "deep reasoning",
286
- xhigh: "extra-deep reasoning",
287
- max: "strongest reasoning",
288
- };
289
-
290
- /** Single strength pick for one agent; the inherit option keeps the effective default.
291
- * `escNote` describes what Esc does at this pick (whole-wizard cancel in the
292
- * full setup vs. ending the per-agent loop in the menu). */
293
- async function pickAgentStrength(
294
- ctx: ExtensionCommandContext,
295
- agentName: string,
296
- current: ThinkingLevel | undefined,
297
- defaultLevel: ThinkingLevel,
298
- defaults: ReadonlyMap<string, ThinkingLevel>,
299
- escNote = "cancels setup",
300
- ): Promise<ThinkingLevel | typeof INHERIT | undefined> {
301
- const options = THINKING_LEVEL_VALUES.map((level) => ({
302
- value: level,
303
- label: current === level ? `${level} — ${THINKING_LEVEL_HINTS[level]} (current)` : `${level} ${THINKING_LEVEL_HINTS[level]}`,
304
- }));
305
- const agentDefault = defaults.get(agentName);
306
- const inheritLabel = agentDefault
307
- ? `(inherit agent default — ${agentDefault})`
308
- : `(inherit global default — ${defaultLevel})`;
309
- const choice = await promptSelectOne(
310
- ctx,
311
- `Thinking strength for "${agentName}"?`,
312
- `Type to filter • ↑/↓ • Enter selects • Esc ${escNote}`,
313
- [{ value: INHERIT, label: inheritLabel }, ...options],
314
- );
315
- if (choice === undefined) return undefined;
316
- return choice === INHERIT ? INHERIT : (choice as ThinkingLevel);
317
- }
318
-
319
- /** Pick one enabled agent to reconfigure. Resolves undefined on Esc. */
320
- async function pickAgentToConfigure(
321
- ctx: ExtensionCommandContext,
322
- enabledAgents: readonly string[],
323
- ): Promise<string | undefined> {
324
- if (enabledAgents.length === 0) {
325
- ctx.ui.notify("No agents are enabled. Enable agents first.", "warning");
326
- return undefined;
327
- }
328
- const items = enabledAgents.map((name) => ({ value: name, label: moduleLabel(name) }));
329
- return promptSelectOne(
330
- ctx,
331
- "Configure which agent?",
332
- "Type to filter • ↑/↓ • PgUp/PgDn • Enter selects • Esc stops",
333
- items,
334
- );
335
- }
336
-
337
- /** Configure one selected agent with the same pool overview/picker used by
338
- * full setup, then retain the existing focused thinking-strength picker. */
339
- async function configureOneAgent(
340
- ctx: ExtensionCommandContext,
341
- enabledAgents: readonly string[],
342
- currentPools: AgentModelPoolMaps,
343
- currentStrengths: Record<string, ThinkingLevel>,
344
- defaultLevel: ThinkingLevel,
345
- defaults: ReadonlyMap<string, ThinkingLevel>,
346
- ): Promise<
347
- | {
348
- name: string;
349
- pools: AgentModelPoolMaps;
350
- strength: ThinkingLevel | typeof INHERIT;
351
- }
352
- | undefined
353
- > {
354
- const name = await pickAgentToConfigure(ctx, enabledAgents);
355
- if (name === undefined) return undefined;
356
- const pools = await editModelPools(ctx, [name], currentPools);
357
- if (pools === undefined) return undefined;
358
- const strength = await pickAgentStrength(
359
- ctx,
360
- name,
361
- currentStrengths[name],
362
- defaultLevel,
363
- defaults,
364
- "stops — earlier agent changes are kept",
365
- );
366
- if (strength === undefined) return undefined;
367
- return { name, pools, strength };
368
- }
369
-
370
- async function pickThinkingLevel(
371
- ctx: ExtensionCommandContext,
372
- current: ThinkingLevel,
373
- ): Promise<ThinkingLevel | undefined> {
374
- const options = THINKING_LEVEL_VALUES.map((level) =>
375
- level === current ? `${level} — ${THINKING_LEVEL_HINTS[level]} (current)` : `${level} — ${THINKING_LEVEL_HINTS[level]}`,
376
- );
377
- const choice = await ctx.ui.select("Default thinking strength for sub-agents?", options);
378
- if (choice === undefined) return undefined;
379
- return THINKING_LEVEL_VALUES.find((level) => choice.startsWith(`${level} —`));
380
- }
381
-
382
- async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Promise<boolean | undefined> {
383
- const on = "On inject the delegation directive into the system prompt (recommended)";
384
- const off = "Off — do not inject (rely on the tool description alone)";
385
- const choice = await ctx.ui.select("Proactive dispatch injection?", [current ? `${on} (current)` : on, current ? off : `${off} (current)`]);
386
- if (choice === undefined) return undefined;
387
- return choice.startsWith("On");
388
- }
389
-
390
- /** Preset steps offered for the two numeric limits (selection-only wizard). */
391
- const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
392
- /** Preset rounds offered for the auto-fix loop (0 disables it). */
393
- const FIX_ROUNDS_STEPS = [0, 1, 2, 3, 5];
394
- /** Preset seconds offered for the idle timeout (0 disables it). */
395
- const IDLE_TIMEOUT_STEPS = [0, 30, 60, 90, 120, 180, 300, 600];
396
-
397
- async function pickCount(
398
- ctx: ExtensionCommandContext,
399
- title: string,
400
- steps: readonly number[],
401
- current: number,
402
- defaultValue: number,
403
- ): Promise<number | undefined> {
404
- const values = [...new Set([...steps, current])].sort((a, b) => a - b);
405
- const options = values.map((value) => {
406
- const tags = [value === current ? "current" : "", value === defaultValue ? "default" : ""]
407
- .filter(Boolean)
408
- .join(", ");
409
- return tags ? `${value} (${tags})` : String(value);
410
- });
411
- const choice = await ctx.ui.select(title, options);
412
- if (choice === undefined) return undefined;
413
- return Number.parseInt(choice, 10);
414
- }
415
-
416
- async function pickScope(ctx: ExtensionCommandContext, current: AgentScope): Promise<AgentScope | undefined> {
417
- const labels: Record<AgentScope, string> = {
418
- user: "user — built-in + ~/.pi/agent/agents (default)",
419
- project: "project built-in + nearest .pi/agents only",
420
- both: "both user agents, overridden by project agents",
421
- };
422
- const options = AGENT_SCOPE_VALUES.map((scope) =>
423
- scope === current ? `${labels[scope]} (current)` : labels[scope],
424
- );
425
- const choice = await ctx.ui.select("Which agent directories to discover from?", options);
426
- if (choice === undefined) return undefined;
427
- const scope = AGENT_SCOPE_VALUES.find((s) => choice.startsWith(s));
428
- return scope;
429
- }
430
-
431
- function keepAgentEntries<T>(record: Record<string, T>, enabled: readonly string[]): Record<string, T> {
432
- const keep = new Set(enabled);
433
- return Object.fromEntries(Object.entries(record).filter(([name]) => keep.has(name)));
434
- }
435
-
436
- async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, base: SubagentsConfig): Promise<void> {
437
- const enabled = await pickEnabledAgents(ctx, base.enabledAgents);
438
- if (enabled === undefined) return notifyCancelled(ctx);
439
-
440
- const thinkingLevel = await pickThinkingLevel(ctx, base.thinkingLevel);
441
- if (thinkingLevel === undefined) return notifyCancelled(ctx);
442
-
443
- const pools = await editModelPools(ctx, enabled, {
444
- agentModels: base.agentModels,
445
- agentBackupModels: base.agentBackupModels,
446
- });
447
- if (pools === undefined) return notifyCancelled(ctx);
448
-
449
- const defaults = builtinThinkingDefaults();
450
- const agentThinkingLevels = keepAgentEntries({ ...base.agentThinkingLevels }, enabled);
451
- for (const agentName of enabled) {
452
- const strength = await pickAgentStrength(
453
- ctx,
454
- agentName,
455
- agentThinkingLevels[agentName],
456
- thinkingLevel,
457
- defaults,
458
- );
459
- if (strength === undefined) return notifyCancelled(ctx);
460
- if (strength === INHERIT) delete agentThinkingLevels[agentName];
461
- else agentThinkingLevels[agentName] = strength;
462
- }
463
-
464
- const visionModel = await pickVisionModel(ctx, base.visionModel);
465
- if (visionModel === undefined) return notifyCancelled(ctx);
466
-
467
- const injection = await pickInjection(ctx, base.proactiveInjection);
468
- if (injection === undefined) return notifyCancelled(ctx);
469
-
470
- const scope = await pickScope(ctx, base.agentScope);
471
- if (scope === undefined) return notifyCancelled(ctx);
472
-
473
- const maxConcurrency = await pickCount(
474
- ctx,
475
- "Max sub-agents running at once (and per parallel call)? (extra work queues)",
476
- CONCURRENCY_STEPS,
477
- base.maxConcurrency,
478
- DEFAULT_MAX_CONCURRENCY,
479
- );
480
- if (maxConcurrency === undefined) return notifyCancelled(ctx);
481
-
482
- const maxFixRounds = await pickCount(
483
- ctx,
484
- "Auto-fix rounds when a reviewer returns REQUEST_CHANGES? (0 = main agent handles fixes)",
485
- FIX_ROUNDS_STEPS,
486
- base.maxFixRounds,
487
- DEFAULT_MAX_FIX_ROUNDS,
488
- );
489
- if (maxFixRounds === undefined) return notifyCancelled(ctx);
490
-
491
- const idleTimeoutSec = await pickCount(
492
- ctx,
493
- "Idle timeout in seconds? (0 = disabled, kills a sub-agent whose output goes silent)",
494
- IDLE_TIMEOUT_STEPS,
495
- base.idleTimeoutSec,
496
- DEFAULT_IDLE_TIMEOUT_SEC,
497
- );
498
- if (idleTimeoutSec === undefined) return notifyCancelled(ctx);
499
-
500
- const next: SubagentsConfig = {
501
- enabledAgents: enabled,
502
- agentModels: keepAgentEntries(pools.agentModels, enabled),
503
- agentBackupModels: keepAgentEntries(pools.agentBackupModels, enabled),
504
- agentThinkingLevels,
505
- thinkingLevel,
506
- notifyOnReviewPass: base.notifyOnReviewPass,
507
- maxResultLines: base.maxResultLines,
508
- proactiveInjection: injection,
509
- agentScope: scope,
510
- maxConcurrency,
511
- maxFixRounds,
512
- idleTimeoutSec,
513
- announcedFeatures: base.announcedFeatures,
514
- };
515
- if (visionModel !== CURRENT_MAIN_MODEL) next.visionModel = visionModel;
516
- await saveConfig(next, configPath);
517
- ctx.ui.notify(`pi-subagents configured. Saved to ${configPath}`, "info");
518
- }
519
-
520
- async function runMenu(ctx: ExtensionCommandContext, configPath: string, config: SubagentsConfig): Promise<void> {
521
- const choice = await ctx.ui.select("pi-subagents is already configured. What would you like to change?", [
522
- "Enable/disable agents",
523
- "Configure an agent (model pool + thinking)",
524
- "Change vision model (image tasks)",
525
- "Toggle proactive injection",
526
- "Change agent scope",
527
- "Change max concurrent sub-agents",
528
- "Change max fix rounds",
529
- "Change idle timeout",
530
- "Full re-setup",
531
- ]);
532
- if (choice === undefined) return notifyCancelled(ctx);
533
-
534
- if (choice.startsWith("Full")) return runFullSetup(ctx, configPath, config);
535
-
536
- let next: SubagentsConfig = {
537
- ...config,
538
- agentModels: { ...config.agentModels },
539
- agentBackupModels: { ...config.agentBackupModels },
540
- };
541
-
542
- if (choice.startsWith("Enable")) {
543
- const enabled = await pickEnabledAgents(ctx, config.enabledAgents);
544
- if (enabled === undefined) return notifyCancelled(ctx);
545
- next.enabledAgents = enabled;
546
- } else if (choice.startsWith("Configure an agent")) {
547
- // Per-agent loop: pick one agent, edit Primary + Backup together, then its
548
- // thinking strength. Esc ends the loop; earlier saved agent changes remain.
549
- const defaults = builtinThinkingDefaults();
550
- let configuredAny = false;
551
- next.agentThinkingLevels = { ...config.agentThinkingLevels };
552
- while (true) {
553
- const picked = await configureOneAgent(
554
- ctx,
555
- next.enabledAgents,
556
- {
557
- agentModels: next.agentModels,
558
- agentBackupModels: next.agentBackupModels,
559
- },
560
- next.agentThinkingLevels,
561
- next.thinkingLevel,
562
- defaults,
563
- );
564
- if (picked === undefined) break;
565
- configuredAny = true;
566
- next.agentModels = picked.pools.agentModels;
567
- next.agentBackupModels = picked.pools.agentBackupModels;
568
- if (picked.strength === INHERIT) delete next.agentThinkingLevels[picked.name];
569
- else next.agentThinkingLevels[picked.name] = picked.strength;
570
- }
571
- if (!configuredAny) return notifyCancelled(ctx);
572
- } else if (choice.startsWith("Toggle")) {
573
- const injection = await pickInjection(ctx, config.proactiveInjection);
574
- if (injection === undefined) return notifyCancelled(ctx);
575
- next.proactiveInjection = injection;
576
- } else if (choice.startsWith("Change vision")) {
577
- const visionModel = await pickVisionModel(ctx, config.visionModel);
578
- if (visionModel === undefined) return notifyCancelled(ctx);
579
- if (visionModel === CURRENT_MAIN_MODEL) delete next.visionModel;
580
- else next.visionModel = visionModel;
581
- } else if (choice.startsWith("Change agent scope")) {
582
- const scope = await pickScope(ctx, config.agentScope);
583
- if (scope === undefined) return notifyCancelled(ctx);
584
- next.agentScope = scope;
585
- } else if (choice.startsWith("Change max concurrent")) {
586
- const maxConcurrency = await pickCount(
587
- ctx,
588
- "Max sub-agents running at once (and per parallel call)? (extra work queues)",
589
- CONCURRENCY_STEPS,
590
- config.maxConcurrency,
591
- DEFAULT_MAX_CONCURRENCY,
592
- );
593
- if (maxConcurrency === undefined) return notifyCancelled(ctx);
594
- next.maxConcurrency = maxConcurrency;
595
- } else if (choice.startsWith("Change max fix")) {
596
- const maxFixRounds = await pickCount(
597
- ctx,
598
- "Auto-fix rounds when a reviewer returns REQUEST_CHANGES? (0 = main agent handles fixes)",
599
- FIX_ROUNDS_STEPS,
600
- config.maxFixRounds,
601
- DEFAULT_MAX_FIX_ROUNDS,
602
- );
603
- if (maxFixRounds === undefined) return notifyCancelled(ctx);
604
- next.maxFixRounds = maxFixRounds;
605
- } else if (choice.startsWith("Change idle")) {
606
- const idleTimeoutSec = await pickCount(
607
- ctx,
608
- "Idle timeout in seconds? (0 = disabled, kills a sub-agent whose output goes silent)",
609
- IDLE_TIMEOUT_STEPS,
610
- config.idleTimeoutSec,
611
- DEFAULT_IDLE_TIMEOUT_SEC,
612
- );
613
- if (idleTimeoutSec === undefined) return notifyCancelled(ctx);
614
- next.idleTimeoutSec = idleTimeoutSec;
615
- }
616
-
617
- await saveConfig(next, configPath);
618
- ctx.ui.notify(`pi-subagents updated. Saved to ${configPath}`, "info");
619
- }
620
-
621
- function notifyCancelled(ctx: ExtensionCommandContext): void {
622
- ctx.ui.notify("pi-subagents setup cancelled.", "info");
623
- }
624
-
625
- /** Entry point for the /subagents-setup command. */
626
- export async function runSetup(ctx: ExtensionCommandContext, configPath: string = getConfigPath()): Promise<void> {
627
- if (ctx.mode !== "tui") {
628
- ctx.ui.notify("/subagents-setup requires Pi's interactive TUI.", "error");
629
- return;
630
- }
631
- try {
632
- const exists = await configExists(configPath);
633
- const config = await loadConfig(configPath);
634
- if (exists) await runMenu(ctx, configPath, config);
635
- else await runFullSetup(ctx, configPath, { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_ENABLED_AGENTS] });
636
- } catch (error) {
637
- ctx.ui.notify(`pi-subagents setup failed: ${errorMessage(error)}`, "error");
638
- }
639
- }
1
+ /**
2
+ * Interactive configuration wizard for /subagents-setup.
3
+ *
4
+ * The UI intentionally has no backup pool or global thinking menu. Each agent
5
+ * gets one optional model override; failures hand directly to the current main
6
+ * model. Thinking defaults to Auto and manual choices are limited to levels Pi
7
+ * reports as supported by the selected model.
8
+ */
9
+
10
+ import { stat } from "node:fs/promises";
11
+ import type { Api, Model } from "@earendil-works/pi-ai";
12
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ AGENT_SCOPE_VALUES,
15
+ BUILTIN_AGENT_NAMES,
16
+ DEFAULT_CONFIG,
17
+ DEFAULT_ENABLED_AGENTS,
18
+ DEFAULT_IDLE_TIMEOUT_SEC,
19
+ DEFAULT_MAX_CONCURRENCY,
20
+ DEFAULT_MAX_FIX_ROUNDS,
21
+ DEFAULT_THINKING_LEVEL,
22
+ type AgentScope,
23
+ type SubagentsConfig,
24
+ type ThinkingLevel,
25
+ errorMessage,
26
+ getConfigPath,
27
+ loadConfig,
28
+ saveConfig,
29
+ } from "./config.ts";
30
+ import {
31
+ CURRENT_MAIN_MODEL,
32
+ applyAgentModelChoice,
33
+ availableModelsInScope,
34
+ buildModelPickerItems,
35
+ currentModelRef,
36
+ findModelByRef,
37
+ modelRef,
38
+ resolveThinkingLevel,
39
+ supportedThinkingLevels,
40
+ type ModelPickerSlot,
41
+ } from "./models.ts";
42
+ import { promptSelectMany, promptSelectOne } from "./ui.ts";
43
+ import { discoverAgents } from "./agents.ts";
44
+
45
+ const AUTO_THINKING = "__auto_thinking__";
46
+
47
+ function actualAgentThinkingDefault(
48
+ ctx: ExtensionCommandContext,
49
+ config: SubagentsConfig,
50
+ agentName: string,
51
+ ): ThinkingLevel {
52
+ const { agents } = discoverAgents(ctx.cwd, {
53
+ scope: config.agentScope,
54
+ enabledNames: config.enabledAgents,
55
+ projectTrusted: ctx.isProjectTrusted(),
56
+ });
57
+ return agents.find((agent) => agent.name === agentName)?.thinking ?? DEFAULT_THINKING_LEVEL;
58
+ }
59
+
60
+ /** Short, selection-friendly descriptions for the built-in agents. */
61
+ const MODULE_HINTS: Record<string, string> = {
62
+ explore: "read-only codebase recon (fast model)",
63
+ worker: "implement / fix / refactor / test (full tools)",
64
+ cleaner: "evidence-first cleanup: audit or verified cuts (full tools)",
65
+ reviewer: "adversarial pre-commit review (read-only)",
66
+ };
67
+
68
+ function moduleLabel(name: string): string {
69
+ const hint = MODULE_HINTS[name];
70
+ return hint ? `${name} — ${hint}` : name;
71
+ }
72
+
73
+ async function configExists(configPath: string): Promise<boolean> {
74
+ try {
75
+ await stat(configPath);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ async function pickEnabledAgents(
83
+ ctx: ExtensionCommandContext,
84
+ current: readonly string[],
85
+ ): Promise<string[] | undefined> {
86
+ const items = BUILTIN_AGENT_NAMES.map((name) => ({ value: name, label: moduleLabel(name) }));
87
+ return promptSelectMany(
88
+ ctx,
89
+ "Enable which sub-agents?",
90
+ "Space toggles • Enter confirms • Esc cancels",
91
+ items,
92
+ current,
93
+ );
94
+ }
95
+
96
+ async function pickConfiguredModel(
97
+ ctx: ExtensionCommandContext,
98
+ title: string,
99
+ slot: ModelPickerSlot,
100
+ configuredRef: string | undefined,
101
+ escNote: string,
102
+ ): Promise<string | undefined> {
103
+ const models = availableModelsInScope(ctx);
104
+ const items = buildModelPickerItems({
105
+ models,
106
+ slot,
107
+ configuredRef,
108
+ mainRef: currentModelRef(ctx),
109
+ });
110
+ return promptSelectOne(
111
+ ctx,
112
+ title,
113
+ `Type to filter by provider, model, capability, or thinking level • ↑/↓ • Enter selects • Esc ${escNote}`,
114
+ items,
115
+ configuredRef ?? CURRENT_MAIN_MODEL,
116
+ );
117
+ }
118
+
119
+ async function pickAgentModel(
120
+ ctx: ExtensionCommandContext,
121
+ agentName: string,
122
+ currentRef: string | undefined,
123
+ escNote = "cancels setup",
124
+ ): Promise<string | undefined> {
125
+ return pickConfiguredModel(ctx, `Model for "${agentName}"?`, "agent", currentRef, escNote);
126
+ }
127
+
128
+ async function pickVisionModel(
129
+ ctx: ExtensionCommandContext,
130
+ currentRef: string | undefined,
131
+ ): Promise<string | undefined> {
132
+ return pickConfiguredModel(
133
+ ctx,
134
+ "Vision model for image tasks? (failure → current main model)",
135
+ "vision",
136
+ currentRef,
137
+ "cancels setup",
138
+ );
139
+ }
140
+
141
+ const THINKING_LEVEL_HINTS: Record<ThinkingLevel, string> = {
142
+ off: "no reasoning tokens",
143
+ minimal: "minimal reasoning",
144
+ low: "light reasoning",
145
+ medium: "balanced reasoning",
146
+ high: "deep reasoning",
147
+ xhigh: "extra-deep reasoning",
148
+ max: "strongest reasoning",
149
+ };
150
+
151
+ function effectiveModelForChoice(
152
+ ctx: ExtensionCommandContext,
153
+ choice: string,
154
+ ): Model<Api> | undefined {
155
+ if (choice === CURRENT_MAIN_MODEL) return ctx.model;
156
+ return findModelByRef(availableModelsInScope(ctx), choice);
157
+ }
158
+
159
+ /** Auto is the default. Manual rows are exactly the levels Pi exposes for the
160
+ * selected model; unsupported xhigh/max entries never appear. */
161
+ async function pickAgentStrength(
162
+ ctx: ExtensionCommandContext,
163
+ agentName: string,
164
+ model: Model<Api> | undefined,
165
+ current: ThinkingLevel | undefined,
166
+ agentDefault: ThinkingLevel,
167
+ escNote = "cancels setup",
168
+ ): Promise<ThinkingLevel | typeof AUTO_THINKING | undefined> {
169
+ const supported = supportedThinkingLevels(model);
170
+ const automatic = resolveThinkingLevel(model, agentDefault);
171
+ // No model metadata, or a non-reasoning model whose only valid value is off:
172
+ // Auto is already the complete and least surprising choice.
173
+ if (supported.length <= 1) return AUTO_THINKING;
174
+
175
+ const currentEffective = current ? resolveThinkingLevel(model, current) : undefined;
176
+ const modelName = model ? modelRef(model) : "current main model";
177
+ const options = [
178
+ {
179
+ value: AUTO_THINKING,
180
+ label: `auto — ${automatic} for ${modelName}${current === undefined ? " (current, recommended)" : " (recommended)"}`,
181
+ },
182
+ ...supported.map((level) => ({
183
+ value: level,
184
+ label: `${level} — ${THINKING_LEVEL_HINTS[level]}${current !== undefined && currentEffective === level ? " (current)" : ""}`,
185
+ })),
186
+ ];
187
+ return promptSelectOne(
188
+ ctx,
189
+ `Thinking for "${agentName}"?`,
190
+ `Only levels supported by ${modelName} are shown • Enter selects • Esc ${escNote}`,
191
+ options,
192
+ current === undefined ? AUTO_THINKING : currentEffective,
193
+ ) as Promise<ThinkingLevel | typeof AUTO_THINKING | undefined>;
194
+ }
195
+
196
+ async function pickAgentToConfigure(
197
+ ctx: ExtensionCommandContext,
198
+ enabledAgents: readonly string[],
199
+ ): Promise<string | undefined> {
200
+ if (enabledAgents.length === 0) {
201
+ ctx.ui.notify("No agents are enabled. Enable agents first.", "warning");
202
+ return undefined;
203
+ }
204
+ return promptSelectOne(
205
+ ctx,
206
+ "Configure which agent?",
207
+ "Type to filter • ↑/↓ • Enter selects • Esc cancels",
208
+ enabledAgents.map((name) => ({ value: name, label: moduleLabel(name) })),
209
+ );
210
+ }
211
+
212
+ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Promise<boolean | undefined> {
213
+ const on = "On — inject the delegation directive (recommended)";
214
+ const off = "Off — rely on tool descriptions only";
215
+ const choice = await ctx.ui.select("Proactive dispatch injection?", [current ? `${on} (current)` : on, current ? off : `${off} (current)`]);
216
+ if (choice === undefined) return undefined;
217
+ return choice.startsWith("On");
218
+ }
219
+
220
+ const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
221
+ const FIX_ROUNDS_STEPS = [0, 1, 2, 3, 5];
222
+ const IDLE_TIMEOUT_STEPS = [0, 30, 60, 90, 120, 180, 300, 600];
223
+
224
+ async function pickCount(
225
+ ctx: ExtensionCommandContext,
226
+ title: string,
227
+ steps: readonly number[],
228
+ current: number,
229
+ defaultValue: number,
230
+ ): Promise<number | undefined> {
231
+ const values = [...new Set([...steps, current])].sort((a, b) => a - b);
232
+ const options = values.map((value) => {
233
+ const tags = [value === current ? "current" : "", value === defaultValue ? "default" : ""]
234
+ .filter(Boolean)
235
+ .join(", ");
236
+ return tags ? `${value} (${tags})` : String(value);
237
+ });
238
+ const choice = await ctx.ui.select(title, options);
239
+ return choice === undefined ? undefined : Number.parseInt(choice, 10);
240
+ }
241
+
242
+ async function pickScope(ctx: ExtensionCommandContext, current: AgentScope): Promise<AgentScope | undefined> {
243
+ const labels: Record<AgentScope, string> = {
244
+ user: "user built-in + ~/.pi/agent/agents (default)",
245
+ project: "project — built-in + nearest .pi/agents only",
246
+ both: "both — user agents, overridden by project agents",
247
+ };
248
+ const options = AGENT_SCOPE_VALUES.map((scope) =>
249
+ scope === current ? `${labels[scope]} (current)` : labels[scope],
250
+ );
251
+ const choice = await ctx.ui.select("Which agent directories to discover from?", options);
252
+ if (choice === undefined) return undefined;
253
+ return AGENT_SCOPE_VALUES.find((scope) => choice.startsWith(scope));
254
+ }
255
+
256
+ function keepAgentEntries<T>(record: Record<string, T>, enabled: readonly string[]): Record<string, T> {
257
+ const keep = new Set(enabled);
258
+ return Object.fromEntries(Object.entries(record).filter(([name]) => keep.has(name)));
259
+ }
260
+
261
+ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, base: SubagentsConfig): Promise<void> {
262
+ const enabled = await pickEnabledAgents(ctx, base.enabledAgents);
263
+ if (enabled === undefined) return notifyCancelled(ctx);
264
+
265
+ let agentModels = keepAgentEntries(base.agentModels, enabled);
266
+ for (const agentName of enabled) {
267
+ const choice = await pickAgentModel(ctx, agentName, agentModels[agentName]);
268
+ if (choice === undefined) return notifyCancelled(ctx);
269
+ agentModels = applyAgentModelChoice(agentModels, agentName, choice);
270
+ }
271
+
272
+ const visionModel = await pickVisionModel(ctx, base.visionModel);
273
+ if (visionModel === undefined) return notifyCancelled(ctx);
274
+ const injection = await pickInjection(ctx, base.proactiveInjection);
275
+ if (injection === undefined) return notifyCancelled(ctx);
276
+ const scope = await pickScope(ctx, base.agentScope);
277
+ if (scope === undefined) return notifyCancelled(ctx);
278
+ const maxConcurrency = await pickCount(
279
+ ctx,
280
+ "Max sub-agents running at once?",
281
+ CONCURRENCY_STEPS,
282
+ base.maxConcurrency,
283
+ DEFAULT_MAX_CONCURRENCY,
284
+ );
285
+ if (maxConcurrency === undefined) return notifyCancelled(ctx);
286
+ const maxFixRounds = await pickCount(
287
+ ctx,
288
+ "Reviewer auto-fix rounds? (0 = main agent handles fixes)",
289
+ FIX_ROUNDS_STEPS,
290
+ base.maxFixRounds,
291
+ DEFAULT_MAX_FIX_ROUNDS,
292
+ );
293
+ if (maxFixRounds === undefined) return notifyCancelled(ctx);
294
+ const idleTimeoutSec = await pickCount(
295
+ ctx,
296
+ "Idle timeout in seconds? (0 = disabled)",
297
+ IDLE_TIMEOUT_STEPS,
298
+ base.idleTimeoutSec,
299
+ DEFAULT_IDLE_TIMEOUT_SEC,
300
+ );
301
+ if (idleTimeoutSec === undefined) return notifyCancelled(ctx);
302
+
303
+ const next: SubagentsConfig = {
304
+ enabledAgents: enabled,
305
+ agentModels,
306
+ // Full setup returns every agent to capability-aware Auto thinking.
307
+ agentThinkingLevels: {},
308
+ notifyOnReviewPass: base.notifyOnReviewPass,
309
+ maxResultLines: base.maxResultLines,
310
+ proactiveInjection: injection,
311
+ agentScope: scope,
312
+ maxConcurrency,
313
+ maxFixRounds,
314
+ idleTimeoutSec,
315
+ announcedFeatures: base.announcedFeatures,
316
+ };
317
+ if (visionModel !== CURRENT_MAIN_MODEL) next.visionModel = visionModel;
318
+ await saveConfig(next, configPath);
319
+ ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
320
+ }
321
+
322
+ async function updateRuntimeSetting(
323
+ ctx: ExtensionCommandContext,
324
+ config: SubagentsConfig,
325
+ ): Promise<SubagentsConfig | undefined> {
326
+ const choice = await ctx.ui.select("Runtime setting", [
327
+ "Proactive injection",
328
+ "Agent scope",
329
+ "Max concurrency",
330
+ "Reviewer auto-fix rounds",
331
+ "Idle timeout",
332
+ ]);
333
+ if (choice === undefined) return undefined;
334
+ const next = { ...config };
335
+ if (choice.startsWith("Proactive")) {
336
+ const value = await pickInjection(ctx, config.proactiveInjection);
337
+ if (value === undefined) return undefined;
338
+ next.proactiveInjection = value;
339
+ } else if (choice.startsWith("Agent scope")) {
340
+ const value = await pickScope(ctx, config.agentScope);
341
+ if (value === undefined) return undefined;
342
+ next.agentScope = value;
343
+ } else if (choice.startsWith("Max concurrency")) {
344
+ const value = await pickCount(ctx, "Max sub-agents running at once?", CONCURRENCY_STEPS, config.maxConcurrency, DEFAULT_MAX_CONCURRENCY);
345
+ if (value === undefined) return undefined;
346
+ next.maxConcurrency = value;
347
+ } else if (choice.startsWith("Reviewer")) {
348
+ const value = await pickCount(ctx, "Reviewer auto-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
349
+ if (value === undefined) return undefined;
350
+ next.maxFixRounds = value;
351
+ } else {
352
+ const value = await pickCount(ctx, "Idle timeout in seconds?", IDLE_TIMEOUT_STEPS, config.idleTimeoutSec, DEFAULT_IDLE_TIMEOUT_SEC);
353
+ if (value === undefined) return undefined;
354
+ next.idleTimeoutSec = value;
355
+ }
356
+ return next;
357
+ }
358
+
359
+ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config: SubagentsConfig): Promise<void> {
360
+ const choice = await ctx.ui.select("pi-subagents settings", [
361
+ "Enable/disable agents",
362
+ "Configure an agent (model + thinking)",
363
+ "Change vision model",
364
+ "Runtime settings",
365
+ "Full re-setup",
366
+ ]);
367
+ if (choice === undefined) return notifyCancelled(ctx);
368
+ if (choice.startsWith("Full")) return runFullSetup(ctx, configPath, config);
369
+
370
+ let next: SubagentsConfig = {
371
+ ...config,
372
+ agentModels: { ...config.agentModels },
373
+ agentThinkingLevels: { ...config.agentThinkingLevels },
374
+ };
375
+ if (choice.startsWith("Enable")) {
376
+ const enabled = await pickEnabledAgents(ctx, config.enabledAgents);
377
+ if (enabled === undefined) return notifyCancelled(ctx);
378
+ next.enabledAgents = enabled;
379
+ next.agentModels = keepAgentEntries(next.agentModels, enabled);
380
+ next.agentThinkingLevels = keepAgentEntries(next.agentThinkingLevels, enabled);
381
+ } else if (choice.startsWith("Configure")) {
382
+ const agentName = await pickAgentToConfigure(ctx, next.enabledAgents);
383
+ if (agentName === undefined) return notifyCancelled(ctx);
384
+ const modelChoice = await pickAgentModel(
385
+ ctx,
386
+ agentName,
387
+ next.agentModels[agentName],
388
+ "cancels agent changes",
389
+ );
390
+ if (modelChoice === undefined) return notifyCancelled(ctx);
391
+ const model = effectiveModelForChoice(ctx, modelChoice);
392
+ const strength = await pickAgentStrength(
393
+ ctx,
394
+ agentName,
395
+ model,
396
+ next.agentThinkingLevels[agentName],
397
+ actualAgentThinkingDefault(ctx, next, agentName),
398
+ "cancels agent changes",
399
+ );
400
+ if (strength === undefined) return notifyCancelled(ctx);
401
+ next.agentModels = applyAgentModelChoice(next.agentModels, agentName, modelChoice);
402
+ if (strength === AUTO_THINKING) delete next.agentThinkingLevels[agentName];
403
+ else next.agentThinkingLevels[agentName] = strength;
404
+ } else if (choice.startsWith("Change vision")) {
405
+ const visionModel = await pickVisionModel(ctx, config.visionModel);
406
+ if (visionModel === undefined) return notifyCancelled(ctx);
407
+ if (visionModel === CURRENT_MAIN_MODEL) delete next.visionModel;
408
+ else next.visionModel = visionModel;
409
+ } else {
410
+ const updated = await updateRuntimeSetting(ctx, next);
411
+ if (updated === undefined) return notifyCancelled(ctx);
412
+ next = updated;
413
+ }
414
+
415
+ await saveConfig(next, configPath);
416
+ ctx.ui.notify(`pi-subagents updated. Saved to ${configPath}`, "info");
417
+ }
418
+
419
+ function notifyCancelled(ctx: ExtensionCommandContext): void {
420
+ ctx.ui.notify("pi-subagents setup cancelled.", "info");
421
+ }
422
+
423
+ /** Entry point for the /subagents-setup command. */
424
+ export async function runSetup(ctx: ExtensionCommandContext, configPath: string = getConfigPath()): Promise<void> {
425
+ if (ctx.mode !== "tui") {
426
+ ctx.ui.notify("/subagents-setup requires Pi's interactive TUI.", "error");
427
+ return;
428
+ }
429
+ try {
430
+ const exists = await configExists(configPath);
431
+ const config = await loadConfig(configPath);
432
+ if (exists) await runMenu(ctx, configPath, config);
433
+ else await runFullSetup(ctx, configPath, { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_ENABLED_AGENTS] });
434
+ } catch (error) {
435
+ ctx.ui.notify(`pi-subagents setup failed: ${errorMessage(error)}`, "error");
436
+ }
437
+ }