@ferris1225/pi-subagents 0.26.2 → 0.27.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
@@ -27,7 +27,10 @@ report their results back to the main agent automatically.
27
27
  - **Parallel fan-out** — independent tasks run at the same time, with a configurable
28
28
  concurrency limit.
29
29
  - **Live progress** — a TUI widget shows each run's status, current activity, model,
30
- token usage (input/output and cache read/write), and elapsed time.
30
+ token usage (input/output and cache read/write), and elapsed time. Auto-fix chain
31
+ rounds hang under their triggering review as a tree, and each finished round stays
32
+ visible with a one-line outcome (what a worker changed, or a re-review's
33
+ PASS/FAIL and what it found) until the chain resolves.
31
34
  - **Per-agent configuration** — enable agents, choose a model and thinking level per
32
35
  agent, and tune limits from `/subagents-setup`.
33
36
  - **Automatic model fallback** — if an agent's model fails at the provider level before
@@ -64,8 +67,9 @@ Several tools now offer some form of sub-agents. What this extension does differ
64
67
  tell you when any of these happened.
65
68
  - **A quality gate that closes the loop.** When a reviewer returns `REVIEW_FAIL`,
66
69
  the extension dispatches a worker briefed with the concrete findings, then a
67
- re-review — up to `maxFixRounds` times — and only then wakes the main agent with
68
- the whole chain. The gate runs itself instead of asking you to babysit it.
70
+ re-review — up to `maxFixRounds` times — and only then wakes the main agent.
71
+ The gate runs itself instead of asking you to babysit it, and the widget shows
72
+ every round as it happens instead of a black box.
69
73
  - **Honest results.** A sub-agent can end its turn with "still working" while its
70
74
  last build actually failed. The completion message surfaces the failed tool
71
75
  calls from the run's final attempt (`completed with N failed tool call(s)`) with
@@ -74,7 +78,12 @@ Several tools now offer some form of sub-agents. What this extension does differ
74
78
  attempt's tool calls are counted — never stale errors from an abandoned one.)
75
79
  - **You can see what it is doing.** The widget shows each run's status, current
76
80
  activity (which tool, which file), model, token usage including cache reads and
77
- writes, and elapsed time — plus soft warnings when a run looks stuck.
81
+ writes, and elapsed time — plus soft warnings when a run looks stuck. When a
82
+ chain finishes, the delivered message is one condensed summary (one line per
83
+ round: verdict + what changed/found, plus aggregate usage) instead of every
84
+ round's raw output stacked together; the final round's full report is attached
85
+ only when its detail is actionable (a FAIL verdict or a crash), and any round's
86
+ full report stays one `subagent_status <id>` call away.
78
87
  - **Recursion is structurally impossible.** Children are leaf processes: the
79
88
  `subagent` tool is excluded from their toolset. No runaway delegation trees.
80
89
  - **Zero runtime dependencies.** It is a plain pi extension — install, configure,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.26.2",
3
+ "version": "0.27.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/fixloop.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
15
+ import { extractKeyFragments, formatUsageCompact } from "./monitor.ts";
15
16
  import type { SubagentsConfig } from "./config.ts";
16
17
 
17
18
  /**
@@ -62,6 +63,98 @@ export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, m
62
63
  ].join("\n");
63
64
  }
64
65
 
66
+ /**
67
+ * One step of an auto-fix chain as delivered: the run id (so the condensed
68
+ * summary can point at per-run detail via subagent_status), the result, and
69
+ * the human-readable role within the chain ("initial review", "fix round 1",
70
+ * "re-review round 2"). runId is undefined for steps that never spawned a run
71
+ * (e.g. an unknown agent).
72
+ */
73
+ export interface ChainStep {
74
+ runId?: number;
75
+ result: SingleResult;
76
+ relation: string;
77
+ }
78
+
79
+ /** Max distinguishing fragments kept in a one-line chain summary. */
80
+ export const CHAIN_SUMMARY_FRAGMENTS_MAX = 3;
81
+
82
+ /** The most telling fragments (paths, quoted phrases, symbols) of a run's final
83
+ * output: for a worker these are the paths it changed, for a reviewer the
84
+ * issues it found. Capped so summaries stay one line. */
85
+ export function chainKeyFragments(result: SingleResult): string[] {
86
+ return extractKeyFragments(getResultOutput(result)).slice(0, CHAIN_SUMMARY_FRAGMENTS_MAX);
87
+ }
88
+
89
+ /**
90
+ * Compact one-line outcome for a finished chain run, shown in the widget so
91
+ * each round reads as what it did: a reviewer reports its verdict plus the
92
+ * key fragments of what it found ("fail · src/index.ts · render()"), a worker
93
+ * the fragments of what it changed. Failed runs and runs with nothing
94
+ * distinctive get no summary.
95
+ */
96
+ export function summarizeChainResult(result: SingleResult): string | undefined {
97
+ if (isFailedResult(result)) return undefined;
98
+ const verdict = result.agent === "reviewer" ? reviewVerdict(getResultOutput(result)) : undefined;
99
+ if (verdict === "pass") return "pass";
100
+ const fragments = chainKeyFragments(result);
101
+ if (verdict === "fail") return fragments.length > 0 ? `fail · ${fragments.join(" · ")}` : "fail";
102
+ return fragments.length > 0 ? fragments.join(" · ") : undefined;
103
+ }
104
+
105
+ /**
106
+ * Condensed, readable summary of a completed auto-fix chain: one line per step
107
+ * (run id, role, verdict / what changed) plus aggregate usage. Full per-step
108
+ * reports stay addressable via `subagent_status <id>`; the caller appends the
109
+ * final step's full block only when its detail is actionable (FAIL verdict or a
110
+ * crash), so the delivered message stays short instead of stacking every
111
+ * round's raw output.
112
+ */
113
+ export function formatChainSummary(steps: readonly ChainStep[]): string {
114
+ const rounds = steps.filter((step) => step.relation.startsWith("fix round")).length;
115
+ const last = steps[steps.length - 1];
116
+ const stepStatus = (step: ChainStep): string => {
117
+ const { result } = step;
118
+ if (result.agent === "reviewer") {
119
+ const verdict = reviewVerdict(getResultOutput(result));
120
+ if (verdict) return verdict.toUpperCase();
121
+ }
122
+ return isFailedResult(result) ? "failed" : "completed";
123
+ };
124
+ const lines = [
125
+ `## Auto-fix chain: ${Math.max(1, rounds)} round${rounds === 1 ? "" : "s"} — final ${stepStatus(last)}`,
126
+ "",
127
+ ];
128
+ for (const step of steps) {
129
+ const fragments = chainKeyFragments(step.result);
130
+ const suffix =
131
+ fragments.length > 0
132
+ ? step.result.agent === "worker"
133
+ ? ` — changed: ${fragments.join(" · ")}`
134
+ : ` — ${fragments.join(" · ")}`
135
+ : "";
136
+ const id = step.runId !== undefined ? `#${step.runId} ` : "";
137
+ lines.push(`- ${id}${step.result.agent} · ${step.relation} · ${stepStatus(step)}${suffix}`);
138
+ }
139
+ const total = steps.reduce(
140
+ (acc, step) => {
141
+ acc.input += step.result.usage.input;
142
+ acc.output += step.result.usage.output;
143
+ acc.cacheRead += step.result.usage.cacheRead;
144
+ acc.cacheWrite += step.result.usage.cacheWrite;
145
+ acc.cost += step.result.usage.cost;
146
+ acc.turns += step.result.usage.turns;
147
+ return acc;
148
+ },
149
+ { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
150
+ );
151
+ const usage = formatUsageCompact(total);
152
+ lines.push("", `Totals: ${steps.length} run${steps.length === 1 ? "" : "s"}${usage ? ` · ${usage}` : ""}`);
153
+ const ids = steps.filter((step) => step.runId !== undefined).map((step) => `#${step.runId}`);
154
+ lines.push(`Full per-run reports (output, usage, failed tools): subagent_status ${ids.join(" ")}`);
155
+ return lines.join("\n");
156
+ }
157
+
65
158
  /**
66
159
  * The re-review brief handed to the reviewer after a worker fix round. Includes
67
160
  * the prior review so the reviewer can verify the fixes without re-discovering
package/src/index.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  type SubagentLiveEvent,
43
43
  type UsageStats,
44
44
  } from "./spawn.ts";
45
- import { buildFixTaskBrief, buildReReviewBrief, shouldTriggerFixLoop } from "./fixloop.ts";
45
+ import { buildFixTaskBrief, buildReReviewBrief, formatChainSummary, shouldTriggerFixLoop, summarizeChainResult, type ChainStep } from "./fixloop.ts";
46
46
  import {
47
47
  activityStateLabel,
48
48
  compactLine,
@@ -469,9 +469,9 @@ export default function (pi: ExtensionAPI): void {
469
469
  task: string,
470
470
  signal: AbortSignal,
471
471
  meta: RunChainMeta,
472
- ): Promise<SingleResult> => {
472
+ ): Promise<{ runId?: number; result: SingleResult }> => {
473
473
  const agent = agents.find((candidate) => candidate.name === agentName);
474
- if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
474
+ if (!agent) return { result: failedStartResult(agentName, task, `Unknown agent: "${agentName}".`) };
475
475
  const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
476
476
  const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel, meta);
477
477
  const onLive = makeLiveHandler(runId);
@@ -490,11 +490,15 @@ export default function (pi: ExtensionAPI): void {
490
490
  },
491
491
  sessionRef,
492
492
  );
493
- finishRun(runId, isFailedResult(result) ? "failed" : "done");
493
+ // Keep the finished round visible in the widget while the chain is
494
+ // still running, with a one-line summary of what it did; the whole
495
+ // group is dropped when the chain resolves (see removeChainGroup).
496
+ monitor.setSummary(runId, summarizeChainResult(result));
497
+ finishRun(runId, isFailedResult(result) ? "failed" : "done", { retain: true });
494
498
  registerRunResult(runId, result);
495
- return result;
499
+ return { runId, result };
496
500
  } catch (error) {
497
- finishRun(runId, "failed");
501
+ finishRun(runId, "failed", { retain: true });
498
502
  const errorMessage = error instanceof Error ? error.message : String(error);
499
503
  const crashed = {
500
504
  ...queuedResult(agent, task, thinkingLevel),
@@ -505,7 +509,7 @@ export default function (pi: ExtensionAPI): void {
505
509
  dispatchFailed: true,
506
510
  };
507
511
  registerRunResult(runId, crashed);
508
- return crashed;
512
+ return { runId, result: crashed };
509
513
  }
510
514
  };
511
515
 
@@ -517,73 +521,90 @@ export default function (pi: ExtensionAPI): void {
517
521
  * The triggering reviewer's run stays visible in the widget (annotated) until
518
522
  * the chain resolves, so the ↳ rows have an obvious parent.
519
523
  */
524
+ /** Drop every widget row belonging to an auto-fix chain; the retained
525
+ * parent row is removed separately (it does not carry the groupId). */
526
+ const removeChainGroup = (groupId: string): void => {
527
+ for (const run of [...monitor.getRuns()]) {
528
+ if (run.groupId === groupId) monitor.removeRun(run.id);
529
+ }
530
+ };
531
+
520
532
  const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string, parentRunId: number): void => {
521
533
  runControllers.set(parentRunId, backgroundQueue.enqueue(
522
534
  async (signal) => {
523
- const chain: SingleResult[] = [initialReviewerResult];
535
+ const chain: ChainStep[] = [
536
+ { runId: parentRunId, result: initialReviewerResult, relation: "initial review" },
537
+ ];
524
538
  let lastReviewer = initialReviewerResult;
525
539
  for (let round = 1; round <= config.maxFixRounds; round++) {
526
540
  if (!sessionActive) break;
527
541
  const fixBrief = buildFixTaskBrief(lastReviewer, round, config.maxFixRounds);
528
- const workerResult = await launchInLoop("worker", fixBrief, signal, {
542
+ const workerStep = await launchInLoop("worker", fixBrief, signal, {
529
543
  groupId: parentGroupId,
530
544
  relationLabel: `fix round ${round}`,
531
545
  });
532
- chain.push(workerResult);
533
- if (!sessionActive || isFailedResult(workerResult)) break;
546
+ chain.push({ ...workerStep, relation: `fix round ${round}` });
547
+ if (!sessionActive || isFailedResult(workerStep.result)) break;
534
548
  const reReviewBrief = buildReReviewBrief(lastReviewer, round);
535
- const reviewResult = await launchInLoop("reviewer", reReviewBrief, signal, {
549
+ const reviewStep = await launchInLoop("reviewer", reReviewBrief, signal, {
536
550
  groupId: parentGroupId,
537
551
  relationLabel: `re-review round ${round}`,
538
552
  });
539
- chain.push(reviewResult);
540
- lastReviewer = reviewResult;
553
+ chain.push({ ...reviewStep, relation: `re-review round ${round}` });
554
+ lastReviewer = reviewStep.result;
541
555
  // A crashed re-review must stop the chain like a crashed worker: its
542
556
  // output (if any) is not a verdict, and feeding it to the next fix
543
557
  // round would brief the worker from garbage.
544
- if (!sessionActive || isFailedResult(reviewResult)) break;
545
- if (reviewVerdict(getResultOutput(reviewResult)) === "pass") break;
558
+ if (!sessionActive || isFailedResult(reviewStep.result)) break;
559
+ if (reviewVerdict(getResultOutput(reviewStep.result)) === "pass") break;
546
560
  }
547
561
  // The chain is done (success, exhaustion, or abort): drop the retained
548
- // parent row, then deliver the whole chain as one group. The loop's
549
- // outcome always wakes the main agent (a passing chain reports
550
- // success, a stuck one needs a human). Register the parent's final
551
- // state (the last chain result) before removal so subagent_wait can
552
- // resolve it.
553
- registerRunResult(parentRunId, chain[chain.length - 1]);
562
+ // parent row and its retained round rows, then deliver one condensed
563
+ // summary. Register the parent's final state (the last chain result)
564
+ // before removal so subagent_wait can resolve it.
565
+ registerRunResult(parentRunId, chain[chain.length - 1].result);
554
566
  runControllers.delete(parentRunId);
567
+ removeChainGroup(parentGroupId);
555
568
  monitor.removeRun(parentRunId);
556
569
  if (!sessionActive) return;
557
- const items: CompletionMessageItem[] = chain.map((r) => {
558
- // A model-level chain run (worker or re-review whose provider never
559
- // produced output) is handed to the main window like any other
560
- // sub-agent run: the block carries the takeover note. Dispatch
561
- // crashes (dispatchFailed) are excluded by the gate itself.
562
- const modelLevel = isFailedResult(r) && isModelLevelFailure(r);
563
- return {
564
- agent: r.agent,
565
- block: modelLevel
566
- ? `${formatCompletionBlock(r, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(r)}`
567
- : formatCompletionBlock(r, config.maxResultLines, ctx.cwd),
570
+ // One compact message instead of every round's raw output: the summary
571
+ // lines cover each step (verdict + what changed/found), and the final
572
+ // step's full report is appended only when its detail is actionable
573
+ // (a FAIL verdict, a crash, or a model-level failure the main agent
574
+ // must take over). Everything else stays one `subagent_status #id`
575
+ // call away.
576
+ const last = chain[chain.length - 1];
577
+ let block = formatChainSummary(chain);
578
+ if (isFailedResult(last.result) && isModelLevelFailure(last.result)) {
579
+ block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(last.result)}`;
580
+ } else if (isFailedResult(last.result) || reviewVerdict(getResultOutput(last.result)) === "fail") {
581
+ block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}`;
582
+ }
583
+ sendCompletionGroup([
584
+ {
585
+ agent: `auto-fix chain (${last.result.agent})`,
586
+ block,
568
587
  triggerTurn: true,
569
- };
570
- });
571
- sendCompletionGroup(items);
588
+ },
589
+ ]);
572
590
  completionBatcher.flush();
573
591
  },
574
592
  () => {
575
- // Cancelled before delivery: clean up the retained parent row (each
576
- // in-flight chain run was already finished by its launchInLoop path).
593
+ // Cancelled before delivery: clean up the retained parent row and
594
+ // every retained chain row (each in-flight chain run was already
595
+ // finished by its launchInLoop path).
577
596
  runControllers.delete(parentRunId);
597
+ removeChainGroup(parentGroupId);
578
598
  monitor.removeRun(parentRunId);
579
599
  },
580
600
  (error) => {
581
601
  // A crash inside the chain orchestration (failed runs are caught by
582
602
  // launchInLoop and delivered as part of the chain) must not vanish:
583
- // drop the retained parent row, notify, and deliver a failed result
603
+ // drop the retained rows, notify, and deliver a failed result
584
604
  // so the main agent knows the chain never completed.
585
605
  registerRunResult(parentRunId, initialReviewerResult);
586
606
  runControllers.delete(parentRunId);
607
+ removeChainGroup(parentGroupId);
587
608
  monitor.removeRun(parentRunId);
588
609
  if (!sessionActive) return;
589
610
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -1307,7 +1328,10 @@ export default function (pi: ExtensionAPI): void {
1307
1328
  const usage = formatUsageCompact(r.usage);
1308
1329
  const tools = r.toolCount ? `${r.toolCount} tool${r.toolCount === 1 ? "" : "s"}` : "";
1309
1330
  const elapsed = formatElapsed(r, now);
1310
- const metaParts = [model, usage, tools, elapsed].filter(Boolean);
1331
+ // The round outcome summary leads the metadata so a finished chain
1332
+ // row reads as what it did ("fail · src/index.ts · render()",
1333
+ // "pass", "src/index.ts · tests/monitor.test.ts").
1334
+ const metaParts = [r.summary, model, usage, tools, elapsed].filter(Boolean);
1311
1335
  // Running is conveyed by the icon + elapsed; spell out the label only for
1312
1336
  // the other states (ready / done / stopped) so they are unambiguous.
1313
1337
  if (r.status !== "running") metaParts.push(statusLabel(r.status));
package/src/monitor.ts CHANGED
@@ -64,6 +64,11 @@ export interface RunView {
64
64
  relationLabel?: string;
65
65
  /** Free-form note shown in the widget next to the status label (e.g. "auto-fix chain running"). */
66
66
  annotation?: string;
67
+ /** One-line outcome summary of a finished chain run, shown in the widget so
68
+ * each auto-fix round reads as what it did: a reviewer reports its verdict
69
+ * plus key fragments of what it found ("fail · src/index.ts · render()"), a
70
+ * worker the fragments of what it changed. Unset for non-chain runs. */
71
+ summary?: string;
67
72
  /** True when a finished run is intentionally kept in the widget (e.g. an
68
73
  * auto-fix chain parent whose chain is still running). beginTurn preserves
69
74
  * retained runs so they are not swept between turns. */
@@ -472,6 +477,14 @@ export class MonitorStore {
472
477
  this.notify();
473
478
  }
474
479
 
480
+ /** Set the run's one-line outcome summary (what a finished chain round did). */
481
+ setSummary(id: number, text: string | undefined): void {
482
+ const run = this.find(id);
483
+ if (!run) return;
484
+ run.summary = text;
485
+ this.notify();
486
+ }
487
+
475
488
  /** Mark a run as retained (kept in the widget despite being finished). */
476
489
  setRetained(id: number, retained: boolean): void {
477
490
  const run = this.find(id);
@@ -517,6 +530,7 @@ export class MonitorStore {
517
530
  const usage = formatUsageCompact(run.usage);
518
531
  const parts = [run.agent];
519
532
  if (run.relationLabel) parts.push(run.relationLabel);
533
+ if (run.summary) parts.push(run.summary);
520
534
  if (run.model) parts.push(run.model);
521
535
  if (run.thinking) parts.push(`thinking ${run.thinking}`);
522
536
  if (usage) parts.push(usage);