@ferris1225/pi-subagents 0.9.0 → 0.11.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
@@ -15,6 +15,7 @@ import {
15
15
  DEFAULT_CONFIG,
16
16
  DEFAULT_ENABLED_AGENTS,
17
17
  DEFAULT_MAX_CONCURRENCY,
18
+ DEFAULT_MAX_FIX_ROUNDS,
18
19
  DEFAULT_MAX_PARALLEL_TASKS,
19
20
  THINKING_LEVEL_VALUES,
20
21
  type AgentScope,
@@ -27,9 +28,19 @@ import {
27
28
  } from "./config.ts";
28
29
  import { availableModelRefs, repairUnavailableModelOverrides } from "./models.ts";
29
30
  import { promptSelectMany, promptSelectOne } from "./ui.ts";
31
+ import { loadBuiltinAgents } from "./agents.ts";
30
32
 
31
33
  const INHERIT = "__inherit__";
32
34
 
35
+ /** Effective per-agent default strength from builtin frontmatter (config overrides win at spawn). */
36
+ function builtinThinkingDefaults(): Map<string, ThinkingLevel> {
37
+ const map = new Map<string, ThinkingLevel>();
38
+ for (const agent of loadBuiltinAgents()) {
39
+ if (agent.thinking) map.set(agent.name, agent.thinking);
40
+ }
41
+ return map;
42
+ }
43
+
33
44
  /** Short, selection-friendly descriptions for the built-in agents. */
34
45
  const MODULE_HINTS: Record<string, string> = {
35
46
  explore: "read-only codebase recon (fast model)",
@@ -65,20 +76,24 @@ async function pickEnabledAgents(
65
76
  );
66
77
  }
67
78
 
68
- async function pickAgentModels(
79
+ async function pickAgentModelsAndStrength(
69
80
  ctx: ExtensionCommandContext,
70
81
  enabledAgents: readonly string[],
71
- current: Record<string, string>,
72
- ): Promise<Record<string, string> | undefined> {
82
+ currentModels: Record<string, string>,
83
+ currentStrengths: Record<string, ThinkingLevel>,
84
+ defaultLevel: ThinkingLevel,
85
+ defaults: ReadonlyMap<string, ThinkingLevel>,
86
+ ): Promise<{ models: Record<string, string>; strengths: Record<string, ThinkingLevel> } | undefined> {
73
87
  const refs = availableModelRefs(ctx);
74
88
  if (refs.length === 0) {
75
89
  ctx.ui.notify("No Pi models are currently available; model overrides left unchanged.", "warning");
76
- return { ...current };
90
+ return { models: { ...currentModels }, strengths: { ...currentStrengths } };
77
91
  }
78
92
 
79
- const result: Record<string, string> = {};
93
+ const models: Record<string, string> = {};
94
+ const strengths: Record<string, ThinkingLevel> = {};
80
95
  for (const name of enabledAgents) {
81
- const currentRef = current[name];
96
+ const currentRef = currentModels[name];
82
97
  const items = [
83
98
  {
84
99
  value: INHERIT,
@@ -95,9 +110,15 @@ async function pickAgentModels(
95
110
  items,
96
111
  );
97
112
  if (choice === undefined) return undefined; // Esc aborts the whole wizard
98
- if (choice !== INHERIT) result[name] = choice;
113
+ if (choice !== INHERIT) models[name] = choice;
114
+
115
+ // Convenience: the model pick is immediately followed by the strength pick,
116
+ // so per-agent model + strength are configured in one pass.
117
+ const strength = await pickAgentStrength(ctx, name, currentStrengths[name], defaultLevel, defaults);
118
+ if (strength === undefined) return undefined; // Esc aborts the whole wizard
119
+ if (strength !== INHERIT) strengths[name] = strength;
99
120
  }
100
- return result;
121
+ return { models, strengths };
101
122
  }
102
123
 
103
124
  const THINKING_LEVEL_HINTS: Record<ThinkingLevel, string> = {
@@ -107,9 +128,52 @@ const THINKING_LEVEL_HINTS: Record<ThinkingLevel, string> = {
107
128
  medium: "balanced reasoning",
108
129
  high: "deep reasoning",
109
130
  xhigh: "extra-deep reasoning",
110
- max: "strongest reasoning (default)",
131
+ max: "strongest reasoning",
111
132
  };
112
133
 
134
+ /** Single strength pick for one agent; the inherit option keeps the effective default. */
135
+ async function pickAgentStrength(
136
+ ctx: ExtensionCommandContext,
137
+ agentName: string,
138
+ current: ThinkingLevel | undefined,
139
+ defaultLevel: ThinkingLevel,
140
+ defaults: ReadonlyMap<string, ThinkingLevel>,
141
+ ): Promise<ThinkingLevel | typeof INHERIT | undefined> {
142
+ const options = THINKING_LEVEL_VALUES.map((level) => ({
143
+ value: level,
144
+ label: current === level ? `${level} — ${THINKING_LEVEL_HINTS[level]} (current)` : `${level} — ${THINKING_LEVEL_HINTS[level]}`,
145
+ }));
146
+ const agentDefault = defaults.get(agentName);
147
+ const inheritLabel = agentDefault
148
+ ? `(inherit agent default — ${agentDefault})`
149
+ : `(inherit global default — ${defaultLevel})`;
150
+ const choice = await promptSelectOne(
151
+ ctx,
152
+ `Thinking strength for "${agentName}"?`,
153
+ "Type to filter • ↑/↓ • Enter selects • Esc cancels setup",
154
+ [{ value: INHERIT, label: inheritLabel }, ...options],
155
+ );
156
+ if (choice === undefined) return undefined;
157
+ return choice === INHERIT ? INHERIT : (choice as ThinkingLevel);
158
+ }
159
+
160
+ /** Strength picks for every enabled agent (inherit keeps the effective default). */
161
+ async function pickAgentStrengths(
162
+ ctx: ExtensionCommandContext,
163
+ enabledAgents: readonly string[],
164
+ currentStrengths: Record<string, ThinkingLevel>,
165
+ defaultLevel: ThinkingLevel,
166
+ defaults: ReadonlyMap<string, ThinkingLevel>,
167
+ ): Promise<Record<string, ThinkingLevel> | undefined> {
168
+ const strengths: Record<string, ThinkingLevel> = {};
169
+ for (const name of enabledAgents) {
170
+ const strength = await pickAgentStrength(ctx, name, currentStrengths[name], defaultLevel, defaults);
171
+ if (strength === undefined) return undefined; // Esc aborts
172
+ if (strength !== INHERIT) strengths[name] = strength;
173
+ }
174
+ return strengths;
175
+ }
176
+
113
177
  async function pickThinkingLevel(
114
178
  ctx: ExtensionCommandContext,
115
179
  current: ThinkingLevel,
@@ -117,7 +181,7 @@ async function pickThinkingLevel(
117
181
  const options = THINKING_LEVEL_VALUES.map((level) =>
118
182
  level === current ? `${level} — ${THINKING_LEVEL_HINTS[level]} (current)` : `${level} — ${THINKING_LEVEL_HINTS[level]}`,
119
183
  );
120
- const choice = await ctx.ui.select("Sub-agent thinking strength?", options);
184
+ const choice = await ctx.ui.select("Default thinking strength for sub-agents?", options);
121
185
  if (choice === undefined) return undefined;
122
186
  return THINKING_LEVEL_VALUES.find((level) => choice.startsWith(`${level} —`));
123
187
  }
@@ -133,6 +197,8 @@ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Pr
133
197
  /** Preset steps offered for the two numeric limits (selection-only wizard). */
134
198
  const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
135
199
  const PARALLEL_TASK_STEPS = [2, 4, 6, 8, 12, 16, 24, 32];
200
+ /** Preset rounds offered for the auto-fix loop (0 disables it). */
201
+ const FIX_ROUNDS_STEPS = [0, 1, 2, 3, 5];
136
202
 
137
203
  async function pickCount(
138
204
  ctx: ExtensionCommandContext,
@@ -202,12 +268,14 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
202
268
  const enabled = await pickEnabledAgents(ctx, base.enabledAgents);
203
269
  if (enabled === undefined) return notifyCancelled(ctx);
204
270
 
205
- const models = await pickAgentModels(ctx, enabled, base.agentModels);
206
- if (models === undefined) return notifyCancelled(ctx);
207
-
271
+ // Global default first, so per-agent strength picks can show "inherit" against it.
208
272
  const thinkingLevel = await pickThinkingLevel(ctx, base.thinkingLevel);
209
273
  if (thinkingLevel === undefined) return notifyCancelled(ctx);
210
274
 
275
+ const defaults = builtinThinkingDefaults();
276
+ const picked = await pickAgentModelsAndStrength(ctx, enabled, base.agentModels, base.agentThinkingLevels, thinkingLevel, defaults);
277
+ if (picked === undefined) return notifyCancelled(ctx);
278
+
211
279
  const injection = await pickInjection(ctx, base.proactiveInjection);
212
280
  if (injection === undefined) return notifyCancelled(ctx);
213
281
 
@@ -230,17 +298,30 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
230
298
  base.maxParallelTasks,
231
299
  DEFAULT_MAX_PARALLEL_TASKS,
232
300
  );
233
- if (maxParallelTasks === undefined) return notifyCancelled(ctx);
301
+ if (maxParallelTasks === undefined) return notifyCancelled(ctx);
234
302
 
235
- const next: SubagentsConfig = {
303
+ const maxFixRounds = await pickCount(
304
+ ctx,
305
+ "Auto-fix rounds when a reviewer returns REQUEST_CHANGES? (0 = main agent handles fixes)",
306
+ FIX_ROUNDS_STEPS,
307
+ base.maxFixRounds,
308
+ DEFAULT_MAX_FIX_ROUNDS,
309
+ );
310
+ if (maxFixRounds === undefined) return notifyCancelled(ctx);
311
+
312
+ const next: SubagentsConfig = {
236
313
  enabledAgents: enabled,
237
- agentModels: repairStaleModels(ctx, models),
314
+ agentModels: repairStaleModels(ctx, picked.models),
315
+ agentThinkingLevels: picked.strengths,
238
316
  thinkingLevel,
317
+ notifyOnReviewPass: base.notifyOnReviewPass,
318
+ maxResultLines: base.maxResultLines,
239
319
  proactiveInjection: injection,
240
320
  agentScope: scope,
241
321
  maxConcurrency,
242
322
  maxParallelTasks,
243
323
  maxSubagentDepth: base.maxSubagentDepth,
324
+ maxFixRounds,
244
325
  };
245
326
  await saveConfig(next, configPath);
246
327
  ctx.ui.notify(`pi-subagents configured. Saved to ${configPath}`, "info");
@@ -255,6 +336,7 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
255
336
  "Change agent scope",
256
337
  "Change max concurrent sub-agents",
257
338
  "Change max parallel tasks",
339
+ "Change max fix rounds",
258
340
  "Full re-setup",
259
341
  ]);
260
342
  if (choice === undefined) return notifyCancelled(ctx);
@@ -268,13 +350,26 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
268
350
  if (enabled === undefined) return notifyCancelled(ctx);
269
351
  next.enabledAgents = enabled;
270
352
  } else if (choice.startsWith("Change agent models")) {
271
- const models = await pickAgentModels(ctx, config.enabledAgents, config.agentModels);
272
- if (models === undefined) return notifyCancelled(ctx);
273
- next.agentModels = repairStaleModels(ctx, models);
353
+ const defaults = builtinThinkingDefaults();
354
+ const picked = await pickAgentModelsAndStrength(
355
+ ctx,
356
+ config.enabledAgents,
357
+ config.agentModels,
358
+ config.agentThinkingLevels,
359
+ config.thinkingLevel,
360
+ defaults,
361
+ );
362
+ if (picked === undefined) return notifyCancelled(ctx);
363
+ next.agentModels = repairStaleModels(ctx, picked.models);
364
+ next.agentThinkingLevels = picked.strengths;
274
365
  } else if (choice.startsWith("Change thinking")) {
366
+ // Global first so per-agent "inherit" labels reflect the value that will be stored.
275
367
  const thinkingLevel = await pickThinkingLevel(ctx, config.thinkingLevel);
276
368
  if (thinkingLevel === undefined) return notifyCancelled(ctx);
277
369
  next.thinkingLevel = thinkingLevel;
370
+ const strengths = await pickAgentStrengths(ctx, config.enabledAgents, config.agentThinkingLevels, thinkingLevel, builtinThinkingDefaults());
371
+ if (strengths === undefined) return notifyCancelled(ctx);
372
+ next.agentThinkingLevels = strengths;
278
373
  } else if (choice.startsWith("Toggle")) {
279
374
  const injection = await pickInjection(ctx, config.proactiveInjection);
280
375
  if (injection === undefined) return notifyCancelled(ctx);
@@ -303,6 +398,16 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
303
398
  );
304
399
  if (maxParallelTasks === undefined) return notifyCancelled(ctx);
305
400
  next.maxParallelTasks = maxParallelTasks;
401
+ } else if (choice.startsWith("Change max fix")) {
402
+ const maxFixRounds = await pickCount(
403
+ ctx,
404
+ "Auto-fix rounds when a reviewer returns REQUEST_CHANGES? (0 = main agent handles fixes)",
405
+ FIX_ROUNDS_STEPS,
406
+ config.maxFixRounds,
407
+ DEFAULT_MAX_FIX_ROUNDS,
408
+ );
409
+ if (maxFixRounds === undefined) return notifyCancelled(ctx);
410
+ next.maxFixRounds = maxFixRounds;
306
411
  }
307
412
 
308
413
  await saveConfig(next, configPath);
package/src/spawn.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  */
12
12
 
13
13
  import { spawn, type ChildProcess } from "node:child_process";
14
+ import { existsSync, mkdirSync, unlinkSync, rmdirSync, writeFileSync } from "node:fs";
14
15
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
15
- import { existsSync, unlinkSync, rmdirSync } from "node:fs";
16
16
  import { tmpdir } from "node:os";
17
17
  import { basename, join } from "node:path";
18
18
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
@@ -51,6 +51,8 @@ export interface SingleResult {
51
51
  stderr: string;
52
52
  usage: UsageStats;
53
53
  model?: string;
54
+ /** Effective thinking strength this run was launched with. */
55
+ thinking?: string;
54
56
  stopReason?: string;
55
57
  errorMessage?: string;
56
58
  }
@@ -88,6 +90,54 @@ export function getFinalOutput(messages: Message[]): string {
88
90
  return "";
89
91
  }
90
92
 
93
+ /**
94
+ * Parse the machine-readable verdict a reviewer emits (see agents/reviewer.md).
95
+ * Only the LAST standalone `VERDICT: REVIEW_PASS/FAIL` line counts, so a report
96
+ * that merely discusses the tokens cannot be misclassified. Returns undefined
97
+ * when no verdict marker is present, so non-review agents are never mistaken
98
+ * for reviews.
99
+ */
100
+ export function reviewVerdict(output: string): "pass" | "fail" | undefined {
101
+ const lines = output.split("\n");
102
+ for (let index = lines.length - 1; index >= 0; index--) {
103
+ const match = /^\s*VERDICT:\s*REVIEW_(PASS|FAIL)\s*$/i.exec(lines[index]);
104
+ if (match) return match[1].toUpperCase() === "PASS" ? "pass" : "fail";
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ /** Hard cap for a single line inside a truncated result (minified blobs must not blow up). */
110
+ export const RESULT_LINE_MAX = 200;
111
+
112
+ export interface TruncatedOutput {
113
+ /** The result text that fits in the completion message. */
114
+ text: string;
115
+ /** True when lines were dropped or shortened, so the full text is written to disk. */
116
+ truncated: boolean;
117
+ }
118
+
119
+ /** Cap result text for the main conversation: keep the first `maxLines` lines, at most RESULT_LINE_MAX chars each. */
120
+ export function truncateResultOutput(output: string, maxLines: number): TruncatedOutput {
121
+ const lines = output.split("\n");
122
+ if (lines.length <= maxLines && lines.every((line) => line.length <= RESULT_LINE_MAX)) {
123
+ return { text: output, truncated: false };
124
+ }
125
+ const kept = lines.slice(0, maxLines).map((line) =>
126
+ line.length > RESULT_LINE_MAX ? `${line.slice(0, RESULT_LINE_MAX)}…` : line,
127
+ );
128
+ return { text: kept.join("\n"), truncated: true };
129
+ }
130
+
131
+ /** Persist the full result where the main agent can read it on demand. Returns the file path. */
132
+ export function writeResultArtifact(output: string, agentName: string): string {
133
+ const dir = join(tmpdir(), "pi-subagents-results");
134
+ mkdirSync(dir, { recursive: true });
135
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
136
+ const filePath = join(dir, `${Date.now()}-${safeName}.md`);
137
+ writeFileSync(filePath, output, "utf8");
138
+ return filePath;
139
+ }
140
+
91
141
  export function isFailedResult(result: SingleResult): boolean {
92
142
  return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
93
143
  }
@@ -238,6 +288,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
238
288
  stderr: "",
239
289
  usage: emptyUsage(),
240
290
  model: agent.model,
291
+ thinking: thinkingLevel,
241
292
  };
242
293
 
243
294
  const emitUpdate = (): void => {