@henryqw/pi-subagent 6.1.0 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ pi install npm:@henryqw/pi-subagent
30
30
  | `delegate_flow` | tool | Package-owned parallel implementation and declared-order Git integration for 1–8 independent units. |
31
31
  | `delegate_flow_continue` | tool | Repair the blocked Flow unit once in its existing worktree. |
32
32
 
33
- All three delegation tool blocks use a compact self-rendered shell and never exceed five physical lines, including Pi's leading spacer: one call line plus at most three result lines in either collapsed or expanded view.
33
+ All three delegation tool blocks use Pi's default boxed shell and background. Their compact custom content is an immutable call label, foreground aggregate partial-result status, and bounded final summaries—no expanded view.
34
34
 
35
35
  ### `delegate_task`
36
36
 
@@ -53,7 +53,7 @@ Parallel mode starts entries concurrently, waits for every entry, and reports th
53
53
 
54
54
  Background workflows are session-scoped. Session shutdown or reload aborts them and may deliver only recoverable-work evidence or no follow-up message.
55
55
 
56
- The transient two-line status widget owns deterministic live progress: status and task summary above thinking or the active tool (with elapsed time and path basename), completed turns, started tools, model, thinking level, tokens, and total duration; terminal activity is Done, Failed, or Stopped. At capacity, it evicts the oldest terminal row so new active work remains visible, and terminal rows otherwise clear on the next real user input. The final `delegate_task` block is deliberately minimal: bounded final summaries, role attribution for parallel/chain, and only retained-worktree recovery paths. It has no expanded view.
56
+ The transient status widget renders one line per child with: status glyph, role, status label, task summary, activity (thinking or active tool with elapsed time and path basename), and metrics (completed turns, started tools, model, thinking level, tokens, total duration). Rows are ordered active-first (working items first, stable insertion order for the rest). A hard six-physical-line maximum applies: when total items are six or fewer, all child rows render; above six, five child rows plus one status-aware overflow line render (`… N more · X working · Y complete · Z failed · W stopped`). Terminal rows clear on the next real user input; active rows persist until the child settles. The final `delegate_task` block is deliberately minimal: bounded final summaries with role attribution for parallel/chain, and only retained-worktree recovery paths. It has no expanded view.
57
57
 
58
58
  Each delegation resolves its own Role, resources, route, and optional worktree request. When available, `isolation: worktree` gives each entry a deterministic separate worktree; non-Git or unborn-`HEAD` contexts may use Main's cwd. Siblings and chain steps never implicitly share one created worktree.
59
59
 
@@ -76,6 +76,18 @@ A rebase that drops all unit commits is a no-op: Flow validates it, skips Review
76
76
 
77
77
  `delegate_task` remains generic with its ordinary isolation behavior. Flow uses the package-shipped Implementer by default and the package-shipped Reviewer only when a unit requests review; same-named user Roles remain supported overrides.
78
78
 
79
+ ### Delegate UI summary
80
+
81
+ | Aspect | Behavior |
82
+ | --- | --- |
83
+ | Call label | `delegate_task · single/parallel/chain · N task(s)`; `delegate_flow · parallel→serial · N unit(s)`; `delegate_flow_continue · repair continuation` |
84
+ | Partial progress | `delegate_task`: aggregate counts (running/pending/complete/failed/skipped); `delegate_flow`: phase transitions (setup → implement → verify/integrate → review → repair) |
85
+ | Widget rows | One line per child: glyph, role, status, task, activity, metrics |
86
+ | Ordering | Active-first stable (working first, then insertion order) |
87
+ | Line cap | 6 physical lines max (≤6 items: all child rows; >6 items: 5 rows + 1 status-aware overflow) |
88
+ | Terminal retention | Active rows persist; terminal rows clear on next user input |
89
+ | Final result | Bounded summaries with recovery paths; no expanded view |
90
+
79
91
  ## Config
80
92
 
81
93
  pi-subagent owns the extension-named config directory `~/.pi/agent/config/pi-subagent/`, which holds two kinds of user-owned configuration: one Markdown file per Role (see [Roles](#roles)) and its own optional JSON file below. Model routing is *not* configured here; children resolve routes through the shared `@henryqw/pi-task-models` config at `~/.pi/agent/config/pi-task-models.json`.
@@ -108,6 +108,7 @@ type FlowState = {
108
108
  };
109
109
 
110
110
  type UsageMeter = { usage?: Usage };
111
+ type FlowProgress = { line: string };
111
112
 
112
113
  type CommandResult = {
113
114
  stdout: string;
@@ -176,9 +177,18 @@ export function parseDelegateFlowContinue(value: unknown): Static<typeof Delegat
176
177
  };
177
178
  }
178
179
 
180
+ function unitCount(count: number): string {
181
+ return `${count} unit${count === 1 ? "" : "s"}`;
182
+ }
183
+
179
184
  function flowCallLabel(args: { units?: unknown }): string {
180
185
  const count = Array.isArray(args.units) ? args.units.length : 0;
181
- return `delegate_flow · working: ${count} unit${count === 1 ? "" : "s"}`;
186
+ return `delegate_flow · parallel→serial · ${unitCount(count)}`;
187
+ }
188
+
189
+ function isFlowProgress(value: unknown): value is FlowProgress {
190
+ return typeof value === "object" && value !== null && !Array.isArray(value)
191
+ && typeof (value as { line?: unknown }).line === "string";
182
192
  }
183
193
 
184
194
  function flowResultLines(text: string): string[] {
@@ -609,6 +619,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
609
619
  signal: AbortSignal | undefined,
610
620
  ctx: ExtensionContext,
611
621
  meter: UsageMeter,
622
+ emitProgress: (line: string) => void,
612
623
  ) => {
613
624
  assertCurrent(flow);
614
625
  const main = flow.main!;
@@ -624,6 +635,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
624
635
 
625
636
  const implementationFailure = settlementFailure(unit.implementation!);
626
637
  if (implementationFailure) return block(flow, unit, "implementer", implementationFailure, meter);
638
+ emitProgress(`verify/integrate · unit ${flow.index + 1}/${flow.units.length}`);
627
639
 
628
640
  let inspected = await inspectUnit(unit, false, signal);
629
641
  assertCurrent(flow);
@@ -690,6 +702,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
690
702
  if (reviewCriterion !== undefined) {
691
703
  const reviewer = flow.reviewer;
692
704
  if (!reviewer) return terminal(flow, "infrastructure", "Flow Reviewer was not resolved for a unit that requires review.", meter);
705
+ emitProgress(`review · unit ${flow.index + 1}/${flow.units.length}`);
693
706
  let evidence;
694
707
  try {
695
708
  evidence = await prepareExactReviewEvidence({ base: main.expectedHead, tip, worktree: unit.worktree.path }, signal);
@@ -787,16 +800,16 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
787
800
  "If a Flow blocks, inspect its classification and call delegate_flow_continue once with explicit repair guidance; modelClass may replace that one repair's current class.",
788
801
  ],
789
802
  parameters: DelegateFlowSchema,
790
- renderShell: "self",
791
803
  renderCall(args, theme, _context) {
792
804
  return renderToolLines([theme.fg("toolTitle", flowCallLabel(args))], theme);
793
805
  },
794
- renderResult(result, _options, theme, _context) {
806
+ renderResult(result, { isPartial }, theme, _context) {
807
+ if (isPartial) return renderToolLines(isFlowProgress(result.details) ? [theme.fg("muted", result.details.line)] : [], theme);
795
808
  const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
796
809
  return renderToolLines(flowResultLines(text), theme);
797
810
  },
798
811
  prepareArguments: parseDelegateFlow,
799
- async execute(toolCallId, params, signal, _onUpdate, ctx) {
812
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
800
813
  const request = parseDelegateFlow(params);
801
814
  if (active) throw new Error("delegate_flow rejected because another Flow is active.");
802
815
  const roles = runtime.loadRoles();
@@ -805,6 +818,10 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
805
818
  const reviewer = needsReviewer ? roles.find(({ name }) => name === "reviewer") : undefined;
806
819
  if (!implementer) throw new Error("delegate_flow requires an implementer Role.");
807
820
  if (needsReviewer && !reviewer) throw new Error("delegate_flow requires a reviewer Role when a unit declares review.");
821
+ const emitProgress = (line: string) => {
822
+ const progress: FlowProgress = { line };
823
+ onUpdate?.({ content: [{ type: "text", text: progress.line }], details: progress });
824
+ };
808
825
  const flow: FlowState = {
809
826
  phase: "running",
810
827
  generation: runtime.getSessionGeneration(),
@@ -818,6 +835,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
818
835
  warnings: [],
819
836
  };
820
837
  active = flow;
838
+ emitProgress(`setup · ${unitCount(request.units.length)}`);
821
839
  const operationSignal = bindSignal(flow, signal);
822
840
  const meter: UsageMeter = {};
823
841
  let setupComplete = false;
@@ -855,6 +873,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
855
873
  await checkMain(flow.main, operationSignal);
856
874
  assertCurrent(flow);
857
875
  setupComplete = true;
876
+ let completedImplementers = 0;
858
877
  const settlements = await Promise.all(flow.units.map((unit, index) => runChild(
859
878
  flow,
860
879
  flow.implementer,
@@ -866,11 +885,15 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
866
885
  operationSignal,
867
886
  ctx,
868
887
  meter,
869
- )));
888
+ ).then((settlement) => {
889
+ completedImplementers += 1;
890
+ emitProgress(`implement · ${completedImplementers}/${flow.units.length} complete`);
891
+ return settlement;
892
+ })));
870
893
  for (const [index, settlement] of settlements.entries()) flow.units[index]!.implementation = settlement;
871
894
  assertCurrent(flow);
872
895
  if (operationSignal.aborted) operationSignal.throwIfAborted();
873
- return await processFlow(flow, toolCallId, operationSignal, ctx, meter);
896
+ return await processFlow(flow, toolCallId, operationSignal, ctx, meter, emitProgress);
874
897
  } catch (error) {
875
898
  if (!setupComplete) {
876
899
  for (const unit of [...flow.units].reverse()) {
@@ -892,16 +915,16 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
892
915
  promptSnippet: "Repair and continue the blocked deterministic Flow",
893
916
  promptGuidelines: ["Call delegate_flow_continue only after delegate_flow reports a repairable block, with explicit guidance addressing that block."],
894
917
  parameters: DelegateFlowContinueSchema,
895
- renderShell: "self",
896
918
  renderCall(_args, theme, _context) {
897
- return renderToolLines([theme.fg("toolTitle", "delegate_flow_continue · working: repair continuation")], theme);
919
+ return renderToolLines([theme.fg("toolTitle", "delegate_flow_continue · repair continuation")], theme);
898
920
  },
899
- renderResult(result, _options, theme, _context) {
921
+ renderResult(result, { isPartial }, theme, _context) {
922
+ if (isPartial) return renderToolLines(isFlowProgress(result.details) ? [theme.fg("muted", result.details.line)] : [], theme);
900
923
  const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
901
924
  return renderToolLines(flowResultLines(text), theme);
902
925
  },
903
926
  prepareArguments: parseDelegateFlowContinue,
904
- async execute(toolCallId, params, signal, _onUpdate, ctx) {
927
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
905
928
  const { guidance, modelClass } = parseDelegateFlowContinue(params);
906
929
  const flow = active;
907
930
  if (!flow) throw new Error("delegate_flow_continue requires an active blocked Flow.");
@@ -916,7 +939,12 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
916
939
  if (modelClass !== undefined) unit.modelClass = modelClass;
917
940
  const operationSignal = bindSignal(flow, signal);
918
941
  const meter: UsageMeter = {};
942
+ const emitProgress = (line: string) => {
943
+ const progress: FlowProgress = { line };
944
+ onUpdate?.({ content: [{ type: "text", text: progress.line }], details: progress });
945
+ };
919
946
  try {
947
+ emitProgress(`repair · unit ${flow.index + 1}/${flow.units.length}`);
920
948
  unit.implementation = await runChild(
921
949
  flow,
922
950
  flow.implementer,
@@ -931,7 +959,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
931
959
  );
932
960
  assertCurrent(flow);
933
961
  if (operationSignal.aborted) operationSignal.throwIfAborted();
934
- return await processFlow(flow, toolCallId, operationSignal, ctx, meter);
962
+ return await processFlow(flow, toolCallId, operationSignal, ctx, meter, emitProgress);
935
963
  } catch (error) {
936
964
  return terminal(flow, "infrastructure", errorText(error), meter);
937
965
  }
@@ -57,7 +57,8 @@ import {
57
57
  const SUBAGENT_TASK = "pi-subagent/delegateTask";
58
58
  const WIDGET_KEY = "subagent-status";
59
59
  const WIDGET_INTERVAL_MS = 80;
60
- const MAX_WIDGET_ROWS = 8;
60
+ const MAX_WIDGET_ITEMS = 8;
61
+ const MAX_WIDGET_LINES = 6;
61
62
  export const MAX_WIDGET_ACTIVE_TOOLS = 8;
62
63
  const DEFAULT_TIMEOUT_POLICY = {
63
64
  idleMs: DEFAULT_TIMEOUT_CONFIG.idleMinutes * 60_000,
@@ -178,9 +179,30 @@ function isWorkflowTransportDetails(value: unknown): value is WorkflowTransportD
178
179
  }
179
180
 
180
181
  function workflowCallLabel(args: { tasks?: unknown; chain?: unknown }): string {
181
- if (Array.isArray(args.chain)) return `delegate_task · working: chain · ${args.chain.length} task${args.chain.length === 1 ? "" : "s"}`;
182
- if (Array.isArray(args.tasks)) return `delegate_task · working: parallel · ${args.tasks.length} task${args.tasks.length === 1 ? "" : "s"}`;
183
- return "delegate_task · working: single · 1 task";
182
+ if (Array.isArray(args.chain)) return `delegate_task · chain · ${args.chain.length} task${args.chain.length === 1 ? "" : "s"}`;
183
+ if (Array.isArray(args.tasks)) return `delegate_task · parallel · ${args.tasks.length} task${args.tasks.length === 1 ? "" : "s"}`;
184
+ return "delegate_task · single · 1 task";
185
+ }
186
+
187
+ function workflowProgressLine(details: WorkflowTransportDetails): string {
188
+ let running = 0;
189
+ let pending = 0;
190
+ let complete = 0;
191
+ let failed = 0;
192
+ let skipped = 0;
193
+ for (const { status } of details.entries) {
194
+ switch (status) {
195
+ case "running": running += 1; break;
196
+ case "pending": pending += 1; break;
197
+ case "succeeded": complete += 1; break;
198
+ case "failed":
199
+ case "rejected": failed += 1; break;
200
+ case "skipped": skipped += 1; break;
201
+ }
202
+ }
203
+ return [[running, "running"], [pending, "pending"], [complete, "complete"], [failed, "failed"], [skipped, "skipped"]]
204
+ .flatMap(([count, label]) => count ? [`${count} ${label}`] : [])
205
+ .join(" · ");
184
206
  }
185
207
 
186
208
  function workflowResultLines(details: WorkflowTransportDetails, theme: Theme): string[] {
@@ -215,18 +237,23 @@ function renderWidgetRows(
215
237
  spinnerIndex: number,
216
238
  theme: Theme,
217
239
  ): string[] {
218
- const visible = items.slice(0, MAX_WIDGET_ROWS);
240
+ const ordered = [...items.filter(({ status }) => status === "working"), ...items.filter(({ status }) => status !== "working")];
241
+ const visible = ordered.slice(0, ordered.length > MAX_WIDGET_LINES ? MAX_WIDGET_LINES - 1 : MAX_WIDGET_LINES);
219
242
  if (!visible.length) return [];
220
- const indent = " ".repeat(Math.min(2, Math.max(0, width - 1)));
221
- const contentWidth = Math.max(0, width - indent.length);
222
- const lines = visible.flatMap((item) => [
223
- truncateToWidth(
224
- `${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} · ${statusLabel(item.status)} · ${theme.fg("text", item.task)}`,
225
- width,
226
- ),
227
- `${indent}${truncateToWidth(`${theme.fg("text", activityLabel(item, now))} · ${theme.fg("muted", activityMetrics(item, now))}`, contentWidth)}`,
228
- ]);
229
- if (items.length > visible.length) lines.push(truncateToWidth(theme.fg("muted", `… ${items.length - visible.length} more`), width));
243
+ const hidden = ordered.slice(visible.length);
244
+ const lines = visible.map((item) => truncateToWidth(
245
+ `${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} · ${statusLabel(item.status)} · ${theme.fg("text", item.task)} · ${theme.fg("text", activityLabel(item, now))} · ${theme.fg("muted", activityMetrics(item, now))}`,
246
+ width,
247
+ ));
248
+ if (hidden.length) {
249
+ const counts: Record<WidgetStatus, number> = { working: 0, success: 0, failure: 0, aborted: 0 };
250
+ for (const { status } of hidden) counts[status] += 1;
251
+ lines.push(truncateToWidth(theme.fg("muted", [
252
+ `… ${hidden.length} more`,
253
+ ...(["working", "success", "failure", "aborted"] as const).flatMap((status) =>
254
+ counts[status] ? [`${counts[status]} ${statusLabel(status)}`] : []),
255
+ ].join(" · ")), width));
256
+ }
230
257
  return lines;
231
258
  }
232
259
 
@@ -357,11 +384,11 @@ export default function subagentExtension(
357
384
  ) => {
358
385
  if (!ctx.hasUI) return;
359
386
  ensureWidget(ctx);
360
- if (!widgetItems.has(id) && widgetItems.size >= MAX_WIDGET_ROWS) {
387
+ if (!widgetItems.has(id) && widgetItems.size >= MAX_WIDGET_ITEMS) {
361
388
  for (const [oldestId, item] of widgetItems) {
362
389
  if (item.status === "working") continue;
363
390
  widgetItems.delete(oldestId);
364
- if (widgetItems.size < MAX_WIDGET_ROWS) break;
391
+ if (widgetItems.size < MAX_WIDGET_ITEMS) break;
365
392
  }
366
393
  }
367
394
  widgetItems.set(id, {
@@ -580,13 +607,14 @@ export default function subagentExtension(
580
607
  "delegate_task background applies to the whole selected workflow and returns before results exist; use it only when the user explicitly asks for non-blocking work.",
581
608
  ],
582
609
  parameters: WorkflowSchema,
583
- renderShell: "self",
584
610
  renderCall(args, theme, _context) {
585
611
  return renderToolLines([theme.fg("toolTitle", workflowCallLabel(args))], theme);
586
612
  },
587
613
  renderResult(result, { isPartial }, theme, _context) {
588
- if (isPartial) return renderToolLines([], theme);
589
614
  const details = result.details;
615
+ if (isPartial) return renderToolLines(isWorkflowTransportDetails(details)
616
+ ? [theme.fg("muted", workflowProgressLine(details))]
617
+ : [], theme);
590
618
  if (isWorkflowTransportDetails(details)) return renderToolLines(workflowResultLines(details, theme), theme);
591
619
  if (typeof details === "object" && details !== null && (details as { background?: unknown }).background === true) {
592
620
  return renderToolLines([theme.fg("muted", "Background workflow accepted.")], theme);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "6.1.0",
3
+ "version": "6.1.1",
4
4
  "description": "Delegate bounded single, parallel, or chained tasks to isolated Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",