@henryqw/pi-subagent 15.0.0 → 15.0.2

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
@@ -64,7 +64,9 @@ An explicit `model` (`provider/modelId`) replaces only the route model and must
64
64
 
65
65
  Parallel tasks start together, settle together, and report in input order. Chains are sequential and fail at the first failure. `{previous}` passes only the immediately preceding successful assistant output.
66
66
 
67
- Foreground failures throw after keeping bounded sibling and recovery evidence. One call has one aggregate 50 KiB cap for Main-visible text. Live updates show task names and statuses without opaque IDs. Final results show summaries first and full evidence below.
67
+ Foreground failures throw after keeping bounded sibling and recovery evidence. One call has one aggregate 50 KiB cap for Main-visible text. Final results show summaries first and full evidence below.
68
+
69
+ The status widget shows each task group name above at most three indented child rows. Each row shows a one-letter Role badge, status, activity, usage, and duration. Flow stages for one unit share that unit's heading.
68
70
 
69
71
  Background work belongs to its launching session. Shutdown or reload aborts it and may leave only recoverable-work evidence or no follow-up message.
70
72
 
@@ -65,6 +65,7 @@ type ChildSettlement =
65
65
  type UnitState = {
66
66
  request: FlowUnitRequest;
67
67
  modelClass: FlowModelClass;
68
+ widgetTaskId: string;
68
69
  worktree: WorktreeInfo;
69
70
  base: string;
70
71
  implementation?: ChildSettlement;
@@ -125,12 +126,14 @@ export interface DelegateFlowRuntime {
125
126
  resolveLaunch: (role: Role, modelClass: FlowModelClass, ctx: ExtensionContext) => ResolvedRoleLaunch;
126
127
  startWidget: (
127
128
  id: string,
129
+ taskId: string,
128
130
  role: string,
129
131
  model: string,
130
132
  thinkingLevel: string | undefined,
131
133
  name: string,
132
134
  ctx: ExtensionContext,
133
135
  ) => void;
136
+ setWidgetTaskRetained: (taskId: string, retained: boolean) => void;
134
137
  updateWidgetTokens: (id: string, tokens: number) => void;
135
138
  updateWidgetActivity: (id: string, event: EphemeralSubagentActivityEvent) => void;
136
139
  finishWidget: (id: string, status: WidgetStatus) => void;
@@ -262,6 +265,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
262
265
  signal ? AbortSignal.any([signal, flow.sessionController.signal]) : flow.sessionController.signal;
263
266
 
264
267
  const invalidateActive = (): void => {
268
+ if (active?.blocked) runtime.setWidgetTaskRetained(active.blocked.unit.widgetTaskId, false);
265
269
  active?.sessionController.abort(new Error("Flow session ended."));
266
270
  active = undefined;
267
271
  };
@@ -346,6 +350,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
346
350
  modelClass: FlowModelClass,
347
351
  task: string,
348
352
  widgetName: string,
353
+ widgetTaskId: string,
349
354
  cwd: string,
350
355
  widgetId: string,
351
356
  signal: AbortSignal | undefined,
@@ -365,7 +370,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
365
370
  if (launch.missingSkills.length) {
366
371
  ctx.ui.notify(`Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`, "warning");
367
372
  }
368
- runtime.startWidget(widgetId, role.name, launch.model.id, launch.thinkingLevel, widgetName, ctx);
373
+ runtime.startWidget(widgetId, widgetTaskId, role.name, launch.model.id, launch.thinkingLevel, widgetName, ctx);
369
374
  started = true;
370
375
  return { launch, task, cwd };
371
376
  },
@@ -506,6 +511,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
506
511
  diagnostic: string,
507
512
  meter: UsageMeter,
508
513
  ) => {
514
+ if (flow.blocked) runtime.setWidgetTaskRetained(flow.blocked.unit.widgetTaskId, false);
509
515
  if (active === flow) active = undefined;
510
516
  return response(flow, "failed", meter, { classification, diagnostic: capOutput(diagnostic) });
511
517
  };
@@ -521,6 +527,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
521
527
  if (unit.repairUsed) return terminal(flow, classification, bounded, meter);
522
528
  flow.phase = "blocked";
523
529
  flow.blocked = { unit, classification, diagnostic: bounded };
530
+ runtime.setWidgetTaskRetained(unit.widgetTaskId, true);
524
531
  return response(flow, "blocked", meter);
525
532
  };
526
533
 
@@ -687,6 +694,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
687
694
  unit.modelClass,
688
695
  reviewerTask(unit.request, reviewCriterion, { base: evidence.base, tip: evidence.tip, patchPath: evidence.patchPath }),
689
696
  unit.request.name,
697
+ unit.widgetTaskId,
690
698
  unit.worktree.cwd,
691
699
  `${toolCallId}:flow:${flow.index}:review`,
692
700
  signal,
@@ -820,6 +828,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
820
828
  flow.units.push({
821
829
  request: unit,
822
830
  modelClass: unit.modelClass,
831
+ widgetTaskId: `${toolCallId}:flow:${index}`,
823
832
  worktree,
824
833
  base: worktree.baseCommit,
825
834
  repairUsed: false,
@@ -839,6 +848,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
839
848
  unit.modelClass,
840
849
  implementerTask(unit.request),
841
850
  unit.request.name,
851
+ unit.widgetTaskId,
842
852
  unit.worktree.cwd,
843
853
  `${toolCallId}:flow:${index}:implement`,
844
854
  operationSignal,
@@ -884,6 +894,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
884
894
  const blocked = flow.blocked;
885
895
  const unit = blocked.unit;
886
896
  if (unit.repairUsed) throw new Error("delegate_flow_continue repair was already used for this Unit.");
897
+ runtime.setWidgetTaskRetained(unit.widgetTaskId, false);
887
898
  flow.phase = "running";
888
899
  flow.blocked = undefined;
889
900
  unit.repairUsed = true;
@@ -901,6 +912,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
901
912
  unit.modelClass,
902
913
  repairTask(unit.request, blocked, guidance),
903
914
  unit.request.name,
915
+ unit.widgetTaskId,
904
916
  unit.worktree.cwd,
905
917
  `${toolCallId}:flow:${flow.index}:repair`,
906
918
  operationSignal,
@@ -60,6 +60,7 @@ const WIDGET_KEY = "subagent-status";
60
60
  const WIDGET_INTERVAL_MS = 80;
61
61
  const MAX_WIDGET_ITEMS = 8;
62
62
  const MAX_WIDGET_LINES = 6;
63
+ const MAX_WIDGET_GROUP_ROWS = 3;
63
64
  export const MAX_WIDGET_ACTIVE_TOOLS = 8;
64
65
  const DEFAULT_TIMEOUT_POLICY = {
65
66
  idleMs: DEFAULT_TIMEOUT_CONFIG.idleMinutes * 60_000,
@@ -87,6 +88,7 @@ type WidgetItem = {
87
88
  role: string;
88
89
  model: string;
89
90
  thinkingLevel: string;
91
+ taskId: string;
90
92
  name: string;
91
93
  tokens: number;
92
94
  startedAt: number;
@@ -164,13 +166,35 @@ function renderWidgetRows(
164
166
  theme: Theme,
165
167
  ): string[] {
166
168
  const ordered = [...items.filter(({ status }) => status === "working"), ...items.filter(({ status }) => status !== "working")];
167
- const visible = ordered.slice(0, ordered.length > MAX_WIDGET_LINES ? MAX_WIDGET_LINES - 1 : MAX_WIDGET_LINES);
168
- if (!visible.length) return [];
169
- const hidden = ordered.slice(visible.length);
170
- const lines = visible.map((item) => truncateToWidth(
171
- `${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} ${theme.fg("text", item.name)} · ${theme.fg("text", activityLabel(item, now))} · ${theme.fg("muted", activityMetrics(item, now))}`,
172
- width,
173
- ));
169
+ if (!ordered.length) return [];
170
+ const groups = new Map<string, { name: string; items: WidgetItem[] }>();
171
+ for (const item of ordered) {
172
+ const group = groups.get(item.taskId);
173
+ if (group) group.items.push(item);
174
+ else groups.set(item.taskId, { name: item.name, items: [item] });
175
+ }
176
+ const maxVisibleLines = ordered.length + groups.size > MAX_WIDGET_LINES ? MAX_WIDGET_LINES - 1 : MAX_WIDGET_LINES;
177
+ const workingGroups = [...groups.values()].filter(({ items }) => items.some(({ status }) => status === "working"));
178
+ const visibleWorkingGroups = new Set(workingGroups.slice(0, Math.floor(maxVisibleLines / 2)));
179
+ let remainingWorkingGroups = visibleWorkingGroups.size;
180
+ const visible = new Set<WidgetItem>();
181
+ const lines: string[] = [];
182
+ for (const group of groups.values()) {
183
+ const working = group.items.some(({ status }) => status === "working");
184
+ if (working && !visibleWorkingGroups.has(group)) continue;
185
+ const reservedLines = working ? --remainingWorkingGroups * 2 : 0;
186
+ const childCount = Math.min(MAX_WIDGET_GROUP_ROWS, group.items.length, maxVisibleLines - lines.length - reservedLines - 1);
187
+ if (childCount < 1) continue;
188
+ lines.push(truncateToWidth(theme.fg("text", group.name), width));
189
+ for (const item of group.items.slice(0, childCount)) {
190
+ visible.add(item);
191
+ lines.push(truncateToWidth(
192
+ ` ${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} ${theme.fg("text", activityLabel(item, now))} · ${theme.fg("muted", activityMetrics(item, now))}`,
193
+ width,
194
+ ));
195
+ }
196
+ }
197
+ const hidden = ordered.filter((item) => !visible.has(item));
174
198
  if (hidden.length) {
175
199
  const counts: Record<WidgetStatus, number> = { working: 0, success: 0, failure: 0, aborted: 0 };
176
200
  for (const { status } of hidden) counts[status] += 1;
@@ -250,6 +274,7 @@ export default function subagentExtension(
250
274
  ].join("\n"), outputPad, 0);
251
275
  });
252
276
  const widgetItems = new Map<string, WidgetItem>();
277
+ const retainedWidgetTaskIds = new Set<string>();
253
278
  // Each child is a full Pi process issuing its own model calls; cap parallel
254
279
  // spend. Precedence: PI_SUBAGENT_MAX_SUBAGENTS env > config/pi-subagent/config.json
255
280
  // maxSubagents > default 5. Invalid present config falls back to the default
@@ -301,6 +326,11 @@ export default function subagentExtension(
301
326
 
302
327
  const requestWidgetRender = () => activeTui?.requestRender();
303
328
 
329
+ const setWidgetTaskRetained = (taskId: string, retained: boolean) => {
330
+ if (retained) retainedWidgetTaskIds.add(taskId);
331
+ else retainedWidgetTaskIds.delete(taskId);
332
+ };
333
+
304
334
  const startWidgetTimer = () => {
305
335
  if (widgetTimer) return;
306
336
  widgetTimer = setInterval(() => {
@@ -324,6 +354,7 @@ export default function subagentExtension(
324
354
 
325
355
  const startWidgetItem = (
326
356
  id: string,
357
+ taskId: string,
327
358
  role: string,
328
359
  model: string,
329
360
  thinkingLevel: string | undefined,
@@ -334,7 +365,7 @@ export default function subagentExtension(
334
365
  ensureWidget(ctx);
335
366
  if (!widgetItems.has(id) && widgetItems.size >= MAX_WIDGET_ITEMS) {
336
367
  for (const [oldestId, item] of widgetItems) {
337
- if (item.status === "working") continue;
368
+ if (item.status === "working" || retainedWidgetTaskIds.has(item.taskId) || item.taskId === taskId) continue;
338
369
  widgetItems.delete(oldestId);
339
370
  if (widgetItems.size < MAX_WIDGET_ITEMS) break;
340
371
  }
@@ -343,6 +374,7 @@ export default function subagentExtension(
343
374
  role: roleBadge(role),
344
375
  model,
345
376
  thinkingLevel: thinkingLevel ?? "default",
377
+ taskId,
346
378
  name,
347
379
  tokens: 0,
348
380
  startedAt: Date.now(),
@@ -434,6 +466,7 @@ export default function subagentExtension(
434
466
  failedToolPatches.clear();
435
467
  stopWidgetTimer();
436
468
  widgetItems.clear();
469
+ retainedWidgetTaskIds.clear();
437
470
  activeTui = undefined;
438
471
  widgetInstalled = false;
439
472
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
@@ -451,7 +484,7 @@ export default function subagentExtension(
451
484
  pi.on("input", (event) => {
452
485
  if (event.source === "extension") return;
453
486
  for (const [id, item] of widgetItems) {
454
- if (item.status !== "working") widgetItems.delete(id);
487
+ if (item.status !== "working" && !retainedWidgetTaskIds.has(item.taskId)) widgetItems.delete(id);
455
488
  }
456
489
  requestWidgetRender();
457
490
  });
@@ -550,6 +583,7 @@ export default function subagentExtension(
550
583
  ...(modelClass === undefined ? {} : { modelClass }),
551
584
  }),
552
585
  startWidget: startWidgetItem,
586
+ setWidgetTaskRetained,
553
587
  updateWidgetTokens,
554
588
  updateWidgetActivity,
555
589
  finishWidget: finishWidgetItem,
@@ -695,7 +729,7 @@ export default function subagentExtension(
695
729
  if (role.isolation === "worktree") {
696
730
  worktree = await createChildWorktree(ctx.cwd, entry.id, undefined, workflowSignal);
697
731
  }
698
- startWidgetItem(entry.id, role.name, launch.model.id, launch.thinkingLevel, entry.delegation.name, ctx);
732
+ startWidgetItem(entry.id, entry.id, role.name, launch.model.id, launch.thinkingLevel, entry.delegation.name, ctx);
699
733
  setState("running", "");
700
734
  emitUpdate(emitToolUpdates);
701
735
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "15.0.0",
3
+ "version": "15.0.2",
4
4
  "description": "Delegate bounded single, parallel, or chained tasks to isolated Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -13,7 +13,9 @@ Before slicing, identify applicable repository prohibitions. If the request or p
13
13
 
14
14
  Use the fewest cohesive units. `delegate_flow` is for independent units expected to commute: split independent outcomes into units, combine or sequence work that overlaps files, APIs, schemas, generated output, package metadata, lockfiles, or invariants, and never divide one invariant across multiple units. Dependent work remains outside Flow; sequence it in one task or ordinary caller-controlled sequencing.
15
15
 
16
- Give every unit a bounded objective, owned scope and exclusions, and its direct validation command/argument array; each delegation must own one concrete outcome with one focused validation story. If the affected flow or scope is not yet known, perform bounded read-only discovery first. Do not pass the parent request unchanged. Choose `modelClass` according to the delegation tool's guidance. Add non-empty `review` only for an explicit judgment that automated validation cannot establish. Call `delegate_flow` with 1–8 units; the runtime always supplies the effective Implementer and supplies the Reviewer only when a unit needs review.
16
+ Give every unit a bounded objective, owned scope and exclusions, and its direct validation command/argument array. Each task packet must name the neighboring behavior that must stay unchanged. Include the exact test name or error when known CI evidence exists. Never claim a validation command matches unknown CI.
17
+
18
+ Each delegation must own one concrete outcome with one focused validation story. Order declared validation from the cheapest focused check to broader required checks. If the affected flow or scope is not yet known, perform bounded read-only discovery first. Do not pass the parent request unchanged. Choose `modelClass` according to the delegation tool's guidance. Add non-empty `review` only for an explicit judgment that automated validation cannot establish. Call `delegate_flow` with 1–8 units; the runtime always supplies the effective Implementer and supplies the Reviewer only when a unit needs review.
17
19
 
18
20
  ## Runtime Flow
19
21
 
@@ -27,7 +29,9 @@ A successful Flow owns integration and cleanup. A blocked outcome is repairable
27
29
  delegate_flow_continue({ guidance: "Address the reported block and complete the bounded unit.", modelClass: "balanced" })
28
30
  ```
29
31
 
30
- Make the guidance specific to the reported implementation, validation, or review failure. Omit `modelClass` to retain an explicit blocked-unit class or otherwise use each frozen Role's default; supply it only to replace both defaults for that one repair. Do not call continuation unless Flow reports a repairable block. If continuation or Flow returns a terminal failure, inspect every retained path reported by the runtime, then reslice or manually recover from Main; do not retry the Flow or guess a rebase resolution. A cleanup warning does not undo successful integration.
32
+ Make the guidance specific to the reported implementation, validation, or review failure. Omit `modelClass` to retain an explicit blocked-unit class or otherwise use each frozen Role's default; supply it only to replace both defaults for that one repair. Do not call continuation unless Flow reports a repairable block. If continuation or Flow returns a terminal failure, inspect every retained path reported by the runtime, then reslice or manually recover from Main; do not retry the Flow or guess a rebase resolution.
33
+
34
+ A cleanup warning does not undo successful integration. Report a cleanup warning from a successful Flow as-is. Do not investigate it unless the user asks or cleanup is part of acceptance.
31
35
 
32
36
  ## Ordinary delegation
33
37