@ferris1225/pi-subagents 0.10.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/README.md CHANGED
@@ -145,7 +145,8 @@ agent's default — its frontmatter `thinking`, else the global default). The gl
145
145
  "agentScope": "user",
146
146
  "maxConcurrency": 4,
147
147
  "maxParallelTasks": 8,
148
- "maxSubagentDepth": 1
148
+ "maxSubagentDepth": 1,
149
+ "maxFixRounds": 2
149
150
  }
150
151
  ```
151
152
 
@@ -162,6 +163,7 @@ agent's default — its frontmatter `thinking`, else the global default). The gl
162
163
  | `maxConcurrency` | How many sub-agent processes run at once (1–16, default 4). Extra work waits in the queue. |
163
164
  | `maxParallelTasks` | Maximum tasks accepted by one parallel `subagent` call (1–32, default 8). |
164
165
  | `maxSubagentDepth` | Depth at which the `subagent` tool is no longer registered (default 1: the main session delegates, children are leaf processes). `0` disables the tool entirely. Read once at extension load. |
166
+ | `maxFixRounds` | Auto-fix rounds when a reviewer returns `REVIEW_FAIL`: the extension dispatches a `worker` (briefed with the review's concrete findings) then a `reviewer` re-review, repeating up to this many times before waking the main agent with the full chain. `0` disables it (the main agent handles fixes itself). Default 2. The reviewer stays read-only and in its own context; the loop is orchestrated by the extension, not by the reviewer. |
165
167
 
166
168
  ### Configuration migration
167
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Focused sub-agent delegation for pi: explore / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -60,6 +60,14 @@ export const MAX_PARALLEL_TASKS_LIMIT = 32;
60
60
  export const DEFAULT_MAX_SUBAGENT_DEPTH = 1;
61
61
  /** Upper bound accepted for maxSubagentDepth (defensive clamp). */
62
62
  export const MAX_SUBAGENT_DEPTH_LIMIT = 4;
63
+ /**
64
+ * How many automatic worker→reviewer fix rounds run when a reviewer returns
65
+ * REVIEW_FAIL before waking the main agent. 0 disables the auto-fix loop
66
+ * (the main agent is woken to dispatch fixes itself). Default: 2.
67
+ */
68
+ export const DEFAULT_MAX_FIX_ROUNDS = 2;
69
+ /** Upper bound accepted for maxFixRounds (defensive clamp). 0 disables the loop. */
70
+ export const MAX_FIX_ROUNDS_LIMIT = 5;
63
71
 
64
72
  export interface SubagentsConfig {
65
73
  /** Agent names that are discoverable and injected. Default: explore, worker, reviewer. */
@@ -91,6 +99,14 @@ export interface SubagentsConfig {
91
99
  maxParallelTasks: number;
92
100
  /** Depth at which the subagent tool is no longer registered. Default: 1. */
93
101
  maxSubagentDepth: number;
102
+ /**
103
+ * Auto-fix rounds when a reviewer returns REVIEW_FAIL: the extension dispatches
104
+ * a worker (briefed with the review's concrete findings) then a reviewer
105
+ * re-review, repeating up to this many times before waking the main agent with
106
+ * the full chain. 0 disables it (the main agent handles fixes itself).
107
+ * Default: 2.
108
+ */
109
+ maxFixRounds: number;
94
110
  }
95
111
 
96
112
  export const DEFAULT_CONFIG: SubagentsConfig = {
@@ -105,6 +121,7 @@ export const DEFAULT_CONFIG: SubagentsConfig = {
105
121
  maxConcurrency: DEFAULT_MAX_CONCURRENCY,
106
122
  maxParallelTasks: DEFAULT_MAX_PARALLEL_TASKS,
107
123
  maxSubagentDepth: DEFAULT_MAX_SUBAGENT_DEPTH,
124
+ maxFixRounds: DEFAULT_MAX_FIX_ROUNDS,
108
125
  };
109
126
 
110
127
  export function getConfigPath(agentDir: string = getAgentDir()): string {
@@ -151,6 +168,7 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
151
168
  maxConcurrency: DEFAULT_CONFIG.maxConcurrency,
152
169
  maxParallelTasks: DEFAULT_CONFIG.maxParallelTasks,
153
170
  maxSubagentDepth: DEFAULT_CONFIG.maxSubagentDepth,
171
+ maxFixRounds: DEFAULT_CONFIG.maxFixRounds,
154
172
  };
155
173
 
156
174
  if (Array.isArray(raw.enabledAgents)) {
@@ -213,6 +231,11 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
213
231
  config.maxSubagentDepth = Math.max(0, Math.min(MAX_SUBAGENT_DEPTH_LIMIT, Math.round(raw.maxSubagentDepth)));
214
232
  }
215
233
 
234
+ // 0 disables the auto-fix loop (main agent handles fixes itself).
235
+ if (typeof raw.maxFixRounds === "number" && Number.isFinite(raw.maxFixRounds)) {
236
+ config.maxFixRounds = Math.max(0, Math.min(MAX_FIX_ROUNDS_LIMIT, Math.round(raw.maxFixRounds)));
237
+ }
238
+
216
239
  return config;
217
240
  }
218
241
 
package/src/fixloop.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Auto-fix loop: when a reviewer returns REVIEW_FAIL, the extension dispatches a
3
+ * worker (briefed with the review's concrete findings) and then a reviewer
4
+ * re-review, repeating up to maxFixRounds times before waking the main agent with
5
+ * the full chain. The reviewer stays read-only and in its own context; the loop
6
+ * is orchestrated by the extension layer, not by the reviewer itself, so the
7
+ * independence guarantee (no self-confirmation bias) is preserved.
8
+ *
9
+ * The main agent is never woken mid-loop: the reviewer's FAIL result is intercepted
10
+ * before delivery, the chain runs in the background, and only the final group
11
+ * (initial review → worker fixes → re-reviews) is delivered at the end.
12
+ */
13
+
14
+ import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
15
+ import type { SubagentsConfig } from "./config.ts";
16
+
17
+ /**
18
+ * Whether a completed result should trigger the auto-fix loop instead of being
19
+ * delivered to the main agent. Only a REVIEW_FAIL verdict from a healthy
20
+ * reviewer run counts; failed processes and passing reviews are delivered
21
+ * normally. Loop-internal re-review results never reach this path (they are
22
+ * awaited inside the loop, not delivered through the completion flow).
23
+ */
24
+ export function shouldTriggerFixLoop(result: SingleResult, config: SubagentsConfig): boolean {
25
+ if (config.maxFixRounds <= 0) return false;
26
+ if (result.agent !== "reviewer") return false;
27
+ if (isFailedResult(result)) return false;
28
+ return reviewVerdict(getResultOutput(result)) === "fail";
29
+ }
30
+
31
+ /**
32
+ * Build the worker task brief for one fix round from a reviewer's findings.
33
+ * The worker gets the full review text so it can address concrete file:line
34
+ * issues, with instructions to fix only blockers and self-verify.
35
+ */
36
+ export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, maxRounds: number): string {
37
+ const review = getResultOutput(reviewerResult);
38
+ const remaining = maxRounds - round;
39
+ return [
40
+ `Auto-fix round ${round} of ${maxRounds} (triggered by a failed review).`,
41
+ ``,
42
+ `A reviewer ran in an isolated context and returned REQUEST_CHANGES. Its full report:`,
43
+ `---`,
44
+ review,
45
+ `---`,
46
+ ``,
47
+ `Fix the concrete blockers the reviewer flagged. Do NOT refactor unrelated code.`,
48
+ `Address every "Critical" item; address "Warnings" only if they are genuine.`,
49
+ `After editing, run the project's format/build/tests when they exist and report`,
50
+ `exactly what you changed (paths + short rationale) so a reviewer can verify.`,
51
+ remaining > 0
52
+ ? `A reviewer will re-review your changes automatically after you finish.`
53
+ : `This is the last auto-fix round; the main agent will be woken with the full chain.`,
54
+ ].join("\n");
55
+ }
56
+
57
+ /**
58
+ * The re-review brief handed to the reviewer after a worker fix round. Includes
59
+ * the prior review so the reviewer can verify the fixes without re-discovering
60
+ * the original issues.
61
+ */
62
+ export function buildReReviewBrief(reviewerResult: SingleResult, round: number): string {
63
+ const review = getResultOutput(reviewerResult);
64
+ return [
65
+ `Re-review after auto-fix round ${round}.`,
66
+ ``,
67
+ `The previous review (REQUEST_CHANGES) found these issues:`,
68
+ `---`,
69
+ review,
70
+ `---`,
71
+ ``,
72
+ `Verify the worker's fixes address each blocker. Run \`git diff\` to see what changed.`,
73
+ `Classify honestly: APPROVE if blockers are resolved, REQUEST_CHANGES if not.`,
74
+ `End with your machine-readable verdict line as usual (VERDICT: REVIEW_PASS / REVIEW_FAIL).`,
75
+ ].join("\n");
76
+ }
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  getFinalOutput,
35
35
  getResultOutput,
36
36
  isFailedResult,
37
+ reviewVerdict,
37
38
  runSingleAgent,
38
39
  truncateResultOutput,
39
40
  writeResultArtifact,
@@ -42,7 +43,8 @@ import {
42
43
  type SubagentLiveEvent,
43
44
  type UsageStats,
44
45
  } from "./spawn.ts";
45
- import { formatTaskSummary, formatToolActivity, monitor, statusColor, statusIcon, statusLabel } from "./monitor.ts";
46
+ import { buildFixTaskBrief, buildReReviewBrief, shouldTriggerFixLoop } from "./fixloop.ts";
47
+ import { formatTaskSummary, formatToolActivity, monitor, statusColor, statusIcon, statusLabel, type RunChainMeta } from "./monitor.ts";
46
48
 
47
49
  const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
48
50
 
@@ -333,6 +335,95 @@ export default function (pi: ExtensionAPI): void {
333
335
  };
334
336
  }
335
337
 
338
+ /**
339
+ * Dispatch one agent inside an auto-fix chain: tracked in the widget with a
340
+ * groupId/relationLabel, but NOT delivered through the completion flow — the
341
+ * chain owner assembles and delivers the whole group at the end.
342
+ */
343
+ const launchInLoop = async (
344
+ agentName: string,
345
+ task: string,
346
+ signal: AbortSignal,
347
+ meta: RunChainMeta,
348
+ ): Promise<SingleResult> => {
349
+ const agent = agents.find((candidate) => candidate.name === agentName);
350
+ if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
351
+ const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
352
+ const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel, meta);
353
+ const onLive = makeLiveHandler(runId);
354
+ try {
355
+ const result = await runSingleAgent({
356
+ defaultCwd: ctx.cwd,
357
+ agent,
358
+ agentName,
359
+ task,
360
+ thinkingLevel,
361
+ signal,
362
+ onLive,
363
+ makeDetails: makeDetails("single", true),
364
+ });
365
+ finishRun(runId, isFailedResult(result) ? "failed" : "done");
366
+ return result;
367
+ } catch (error) {
368
+ finishRun(runId, "failed");
369
+ const errorMessage = error instanceof Error ? error.message : String(error);
370
+ return {
371
+ ...queuedResult(agent, task, thinkingLevel),
372
+ exitCode: 1,
373
+ stderr: errorMessage,
374
+ stopReason: signal.aborted ? "aborted" : "error",
375
+ errorMessage,
376
+ };
377
+ }
378
+ };
379
+
380
+ /**
381
+ * Run the auto-fix chain in the background: worker (briefed with the review's
382
+ * findings) → reviewer re-review, up to maxFixRounds times. The main agent is
383
+ * not woken mid-loop; the full chain is delivered as one group at the end.
384
+ * Failures short-circuit: a crashed worker skips its re-review and delivers.
385
+ */
386
+ const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string): void => {
387
+ backgroundQueue.enqueue(
388
+ async (signal) => {
389
+ const chain: SingleResult[] = [initialReviewerResult];
390
+ let lastReviewer = initialReviewerResult;
391
+ for (let round = 1; round <= config.maxFixRounds; round++) {
392
+ if (!sessionActive) break;
393
+ const fixBrief = buildFixTaskBrief(lastReviewer, round, config.maxFixRounds);
394
+ const workerResult = await launchInLoop("worker", fixBrief, signal, {
395
+ groupId: parentGroupId,
396
+ relationLabel: `fix round ${round}`,
397
+ });
398
+ chain.push(workerResult);
399
+ if (!sessionActive || isFailedResult(workerResult)) break;
400
+ const reReviewBrief = buildReReviewBrief(lastReviewer, round);
401
+ const reviewResult = await launchInLoop("reviewer", reReviewBrief, signal, {
402
+ groupId: parentGroupId,
403
+ relationLabel: `re-review round ${round}`,
404
+ });
405
+ chain.push(reviewResult);
406
+ lastReviewer = reviewResult;
407
+ if (!sessionActive) break;
408
+ if (reviewVerdict(getResultOutput(reviewResult)) === "pass") break;
409
+ }
410
+ if (!sessionActive) return;
411
+ // Deliver the whole chain as one group; the loop's outcome always wakes
412
+ // the main agent (a passing chain reports success, a stuck one needs a human).
413
+ const items: CompletionMessageItem[] = chain.map((r) => ({
414
+ agent: r.agent,
415
+ block: formatCompletionBlock(r, config.maxResultLines),
416
+ triggerTurn: true,
417
+ }));
418
+ sendCompletionGroup(items);
419
+ completionBatcher.flush();
420
+ },
421
+ () => {
422
+ // Cancelled: each in-flight run was already finished by its launchInLoop path.
423
+ },
424
+ );
425
+ };
426
+
336
427
  const startBackground = (agentName: string, task: string, cwd?: string): SingleResult => {
337
428
  const agent = agents.find((candidate) => candidate.name === agentName);
338
429
  if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
@@ -371,6 +462,15 @@ export default function (pi: ExtensionAPI): void {
371
462
  }
372
463
 
373
464
  if (!sessionActive) return;
465
+ // Auto-fix loop: a REVIEW_FAIL from a main-agent-dispatched reviewer
466
+ // triggers a worker→reviewer chain (up to maxFixRounds) without waking
467
+ // the main agent. Loop-internal re-reviews never reach here (they are
468
+ // awaited inside launchInLoop); the initial review is delivered with
469
+ // the chain at the end.
470
+ if (shouldTriggerFixLoop(result, config)) {
471
+ startFixLoop(result, `fix-${runId}`);
472
+ return;
473
+ }
374
474
  const failed = isFailedResult(result);
375
475
  const completion: CompletionMessageItem = {
376
476
  agent: result.agent,
@@ -518,7 +618,10 @@ export default function (pi: ExtensionAPI): void {
518
618
  for (const r of runs) {
519
619
  const icon = statusIcon(r.status, theme);
520
620
  const label = theme.fg(statusColor(r.status), statusLabel(r.status));
521
- lines.push(truncateToWidth(` ${icon} ${monitor.summarize(r)} · ${label}`, width, ""));
621
+ // Chain-internal runs (auto-fix worker/reviewer) indent under their
622
+ // parent reviewer; summarize() already carries the relationLabel.
623
+ const head = r.groupId ? theme.fg("dim", " ↳ ") : " ";
624
+ lines.push(truncateToWidth(`${head}${icon} ${monitor.summarize(r)} · ${label}`, width, ""));
522
625
  if (r.status === "queued" || r.status === "running") {
523
626
  lines.push(truncateToWidth(theme.fg("dim", ` task: ${formatTaskSummary(r.task)}`), width, ""));
524
627
  }
package/src/monitor.ts CHANGED
@@ -36,6 +36,16 @@ export interface RunView {
36
36
  startedAt?: number;
37
37
  /** Epoch ms when the run finished (set on "done"/"failed"). */
38
38
  endedAt?: number;
39
+ /** When set, this run belongs to an auto-fix chain (e.g. worker fixing a reviewer's findings). */
40
+ groupId?: string;
41
+ /** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
42
+ relationLabel?: string;
43
+ }
44
+
45
+ /** Optional chain metadata for runs spawned by an auto-fix loop. */
46
+ export interface RunChainMeta {
47
+ groupId?: string;
48
+ relationLabel?: string;
39
49
  }
40
50
 
41
51
  // ---------------------------------------------------------------------------
@@ -168,7 +178,7 @@ export class MonitorStore {
168
178
  this.notify();
169
179
  }
170
180
 
171
- addRun(agent: string, task: string, model?: string, thinking?: string): number {
181
+ addRun(agent: string, task: string, model?: string, thinking?: string, meta?: RunChainMeta): number {
172
182
  const id = this.nextId++;
173
183
  this.runs.push({
174
184
  id,
@@ -178,6 +188,8 @@ export class MonitorStore {
178
188
  thinking,
179
189
  status: "queued",
180
190
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
191
+ ...(meta?.groupId ? { groupId: meta.groupId } : {}),
192
+ ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
181
193
  });
182
194
  this.notify();
183
195
  return id;
@@ -233,6 +245,7 @@ export class MonitorStore {
233
245
  summarize(run: RunView): string {
234
246
  const usage = formatUsageCompact(run.usage);
235
247
  const parts = [run.agent];
248
+ if (run.relationLabel) parts.push(run.relationLabel);
236
249
  if (run.model) parts.push(run.model);
237
250
  if (run.thinking) parts.push(`thinking ${run.thinking}`);
238
251
  if (usage) parts.push(usage);
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,
@@ -196,6 +197,8 @@ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Pr
196
197
  /** Preset steps offered for the two numeric limits (selection-only wizard). */
197
198
  const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
198
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];
199
202
 
200
203
  async function pickCount(
201
204
  ctx: ExtensionCommandContext,
@@ -295,9 +298,18 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
295
298
  base.maxParallelTasks,
296
299
  DEFAULT_MAX_PARALLEL_TASKS,
297
300
  );
298
- if (maxParallelTasks === undefined) return notifyCancelled(ctx);
301
+ if (maxParallelTasks === undefined) return notifyCancelled(ctx);
302
+
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);
299
311
 
300
- const next: SubagentsConfig = {
312
+ const next: SubagentsConfig = {
301
313
  enabledAgents: enabled,
302
314
  agentModels: repairStaleModels(ctx, picked.models),
303
315
  agentThinkingLevels: picked.strengths,
@@ -309,6 +321,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
309
321
  maxConcurrency,
310
322
  maxParallelTasks,
311
323
  maxSubagentDepth: base.maxSubagentDepth,
324
+ maxFixRounds,
312
325
  };
313
326
  await saveConfig(next, configPath);
314
327
  ctx.ui.notify(`pi-subagents configured. Saved to ${configPath}`, "info");
@@ -323,6 +336,7 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
323
336
  "Change agent scope",
324
337
  "Change max concurrent sub-agents",
325
338
  "Change max parallel tasks",
339
+ "Change max fix rounds",
326
340
  "Full re-setup",
327
341
  ]);
328
342
  if (choice === undefined) return notifyCancelled(ctx);
@@ -384,6 +398,16 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
384
398
  );
385
399
  if (maxParallelTasks === undefined) return notifyCancelled(ctx);
386
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;
387
411
  }
388
412
 
389
413
  await saveConfig(next, configPath);