@henryqw/pi-subagent 5.0.0 → 6.1.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.
@@ -1,6 +1,7 @@
1
+ import { basename } from "node:path";
1
2
  import type { Usage } from "@earendil-works/pi-ai";
2
3
  import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
3
- import { type Component, type TUI, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
+ import { type Component, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
4
5
  import {
5
6
  availableTaskModels,
6
7
  type ThinkingLevel,
@@ -21,6 +22,7 @@ import {
21
22
  loadRoles,
22
23
  resolveTaskRoute,
23
24
  worktreeContextNote,
25
+ type EphemeralSubagentActivityEvent,
24
26
  type EphemeralSubagentResult,
25
27
  type EphemeralSubagentTimeout,
26
28
  type Role,
@@ -29,6 +31,7 @@ import {
29
31
  } from "@henryqw/pi-subagent";
30
32
  import { DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
31
33
  import { registerDelegateFlow } from "./delegate-flow.ts";
34
+ import { renderToolLines } from "./tool-render.ts";
32
35
  import { runDelegation } from "./delegation.ts";
33
36
  import {
34
37
  formatBackgroundWorkflowResult,
@@ -55,6 +58,7 @@ const SUBAGENT_TASK = "pi-subagent/delegateTask";
55
58
  const WIDGET_KEY = "subagent-status";
56
59
  const WIDGET_INTERVAL_MS = 80;
57
60
  const MAX_WIDGET_ROWS = 8;
61
+ export const MAX_WIDGET_ACTIVE_TOOLS = 8;
58
62
  const DEFAULT_TIMEOUT_POLICY = {
59
63
  idleMs: DEFAULT_TIMEOUT_CONFIG.idleMinutes * 60_000,
60
64
  maxMs: DEFAULT_TIMEOUT_CONFIG.maxMinutes * 60_000,
@@ -71,6 +75,12 @@ export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined)
71
75
  };
72
76
  }
73
77
  type WidgetStatus = "working" | "success" | "failure" | "aborted";
78
+ type WidgetActiveTool = {
79
+ toolName: string;
80
+ path?: string;
81
+ startedAt: number;
82
+ order: number;
83
+ };
74
84
  type WidgetItem = {
75
85
  role: string;
76
86
  model: string;
@@ -80,6 +90,11 @@ type WidgetItem = {
80
90
  startedAt: number;
81
91
  status: WidgetStatus;
82
92
  finishedAt?: number;
93
+ completedAssistantTurns: number;
94
+ startedToolCount: number;
95
+ activeTools: Map<string, WidgetActiveTool>;
96
+ activeToolId?: string;
97
+ activityOrder: number;
83
98
  };
84
99
 
85
100
  function taskSummary(task: string): string {
@@ -111,6 +126,34 @@ function statusLabel(status: WidgetStatus): string {
111
126
  }
112
127
  }
113
128
 
129
+ function activityLabel(item: WidgetItem, now: number): string {
130
+ if (item.status === "success") return "Done";
131
+ if (item.status === "failure") return "Failed";
132
+ if (item.status === "aborted") return "Stopped";
133
+ const activeTool = item.activeToolId === undefined ? undefined : item.activeTools.get(item.activeToolId);
134
+ if (!activeTool) return "thinking…";
135
+ return [
136
+ activeTool.toolName,
137
+ formatDuration(now - activeTool.startedAt),
138
+ ...(activeTool.path === undefined ? [] : [activeTool.path]),
139
+ ].join(" · ");
140
+ }
141
+
142
+ function activityMetrics(item: WidgetItem, now: number): string {
143
+ return [
144
+ ...(item.completedAssistantTurns === 0
145
+ ? []
146
+ : [`${item.completedAssistantTurns} turn${item.completedAssistantTurns === 1 ? "" : "s"}`]),
147
+ ...(item.startedToolCount === 0
148
+ ? []
149
+ : [`${item.startedToolCount} tool${item.startedToolCount === 1 ? "" : "s"}`]),
150
+ item.model,
151
+ item.thinkingLevel,
152
+ `${formatTokens(item.tokens)} tok`,
153
+ formatDuration((item.finishedAt ?? now) - item.startedAt),
154
+ ].join(" · ");
155
+ }
156
+
114
157
  function isWorkflowTransportDetails(value: unknown): value is WorkflowTransportDetails {
115
158
  const isRecord = (candidate: unknown): candidate is Record<string, unknown> => typeof candidate === "object" && candidate !== null && !Array.isArray(candidate);
116
159
  const isOptionalString = (candidate: unknown) => candidate === undefined || typeof candidate === "string";
@@ -134,18 +177,34 @@ function isWorkflowTransportDetails(value: unknown): value is WorkflowTransportD
134
177
  && entries.every((entry, index) => index === 0 || (entry as { index: number }).index > (entries[index - 1] as { index: number }).index);
135
178
  }
136
179
 
137
- function renderWorkflowResult(details: WorkflowTransportDetails, width: number, theme: Theme): string[] {
180
+ 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";
184
+ }
185
+
186
+ function workflowResultLines(details: WorkflowTransportDetails, theme: Theme): string[] {
138
187
  if (details.entries.some(({ status }) => status === "pending" || status === "running")) return [];
139
- return details.entries.flatMap((entry) => {
140
- if (entry.status === "skipped") return [];
188
+ const entries = details.entries.filter(({ status }) => status !== "skipped");
189
+ const withRecovery = (entry: typeof entries[0]) => entry.worktree && !entry.worktree.pruned;
190
+ const isTerminalFailure = (entry: typeof entries[0]) => entry.status === "failed" || entry.status === "rejected";
191
+ const sorted = [...entries].sort((a, b) => {
192
+ const aFailure = isTerminalFailure(a);
193
+ const bFailure = isTerminalFailure(b);
194
+ if (aFailure !== bFailure) return aFailure ? -1 : 1;
195
+ const aRecovery = !aFailure && withRecovery(a);
196
+ const bRecovery = !bFailure && withRecovery(b);
197
+ if (aRecovery !== bRecovery) return aRecovery ? -1 : 1;
198
+ return 0;
199
+ });
200
+ return sorted.map((entry) => {
141
201
  const summary = entry.summary || "(no output)";
142
202
  const text = details.mode === "single" ? summary : `${entry.role}: ${summary}`;
143
203
  const style = entry.status === "failed" || entry.status === "rejected" ? "error" : "text";
144
- const recovery = entry.worktree && !entry.worktree.pruned ? `Recovery: ${entry.worktree.path}` : undefined;
145
- return [
146
- ...wrapTextWithAnsi(theme.fg(style, text), Math.max(1, width)),
147
- ...(recovery ? wrapTextWithAnsi(theme.fg("warning", recovery), Math.max(1, width)) : []),
148
- ];
204
+ const recovery = withRecovery(entry) ? `Recovery: ${entry.worktree!.path}` : undefined;
205
+ return recovery === undefined
206
+ ? theme.fg(style, text)
207
+ : `${theme.fg("warning", recovery)} · ${theme.fg(style, text)}`;
149
208
  });
150
209
  }
151
210
 
@@ -159,13 +218,15 @@ function renderWidgetRows(
159
218
  const visible = items.slice(0, MAX_WIDGET_ROWS);
160
219
  if (!visible.length) return [];
161
220
  const indent = " ".repeat(Math.min(2, Math.max(0, width - 1)));
162
- const contentWidth = Math.max(1, width - indent.length);
221
+ const contentWidth = Math.max(0, width - indent.length);
163
222
  const lines = visible.flatMap((item) => [
164
- ...wrapTextWithAnsi(`${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} · ${statusLabel(item.status)}`, Math.max(1, width)),
165
- ...wrapTextWithAnsi(theme.fg("text", item.task), contentWidth).map((line) => `${indent}${line}`),
166
- ...wrapTextWithAnsi(theme.fg("muted", `${item.model} · ${item.thinkingLevel} · ${formatTokens(item.tokens)} tok · ${formatDuration((item.finishedAt ?? now) - item.startedAt)}`), contentWidth).map((line) => `${indent}${line}`),
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)}`,
167
228
  ]);
168
- if (items.length > visible.length) lines.push(...wrapTextWithAnsi(theme.fg("muted", `… ${items.length - visible.length} more`), Math.max(1, width)));
229
+ if (items.length > visible.length) lines.push(truncateToWidth(theme.fg("muted", `… ${items.length - visible.length} more`), width));
169
230
  return lines;
170
231
  }
171
232
 
@@ -311,6 +372,10 @@ export default function subagentExtension(
311
372
  tokens: 0,
312
373
  startedAt: Date.now(),
313
374
  status: "working",
375
+ completedAssistantTurns: 0,
376
+ startedToolCount: 0,
377
+ activeTools: new Map(),
378
+ activityOrder: 0,
314
379
  });
315
380
  startWidgetTimer();
316
381
  requestWidgetRender();
@@ -323,11 +388,55 @@ export default function subagentExtension(
323
388
  requestWidgetRender();
324
389
  };
325
390
 
391
+ const updateWidgetActivity = (id: string, event: EphemeralSubagentActivityEvent) => {
392
+ const item = widgetItems.get(id);
393
+ if (!item || item.status !== "working") return;
394
+ switch (event.type) {
395
+ case "tool_execution_start": {
396
+ if (item.activeTools.has(event.toolCallId)) break;
397
+ if (item.activeTools.size >= MAX_WIDGET_ACTIVE_TOOLS) {
398
+ let oldest: [string, WidgetActiveTool] | undefined;
399
+ for (const candidate of item.activeTools) {
400
+ if (!oldest || candidate[1].order < oldest[1].order) oldest = candidate;
401
+ }
402
+ if (oldest) item.activeTools.delete(oldest[0]);
403
+ }
404
+ const path = event.path === undefined ? undefined : basename(event.path);
405
+ item.startedToolCount += 1;
406
+ item.activeTools.set(event.toolCallId, {
407
+ toolName: event.toolName,
408
+ ...(path ? { path } : {}),
409
+ startedAt: Date.now(),
410
+ order: ++item.activityOrder,
411
+ });
412
+ item.activeToolId = event.toolCallId;
413
+ break;
414
+ }
415
+ case "tool_execution_end": {
416
+ item.activeTools.delete(event.toolCallId);
417
+ if (item.activeToolId === event.toolCallId) {
418
+ let latest: [string, WidgetActiveTool] | undefined;
419
+ for (const candidate of item.activeTools) {
420
+ if (!latest || candidate[1].order > latest[1].order) latest = candidate;
421
+ }
422
+ item.activeToolId = latest?.[0];
423
+ }
424
+ break;
425
+ }
426
+ case "message_end":
427
+ item.completedAssistantTurns += 1;
428
+ break;
429
+ }
430
+ requestWidgetRender();
431
+ };
432
+
326
433
  const finishWidgetItem = (id: string, status: Exclude<WidgetStatus, "working">) => {
327
434
  const item = widgetItems.get(id);
328
435
  if (!item) return;
329
436
  item.status = status;
330
437
  item.finishedAt = Date.now();
438
+ item.activeTools.clear();
439
+ item.activeToolId = undefined;
331
440
  if (![...widgetItems.values()].some(({ status }) => status === "working")) stopWidgetTimer();
332
441
  requestWidgetRender();
333
442
  };
@@ -443,15 +552,18 @@ export default function subagentExtension(
443
552
  maxRuntimeMs: timeoutPolicy.maxMs,
444
553
  getSessionGeneration: () => sessionEpoch,
445
554
  loadRoles,
446
- resolveLaunch: (role, ctx) => {
555
+ resolveLaunch: (role, modelClass, ctx) => {
447
556
  const launchCtx = latestCtx ?? ctx;
448
557
  return createRoleLaunch(pi, launchCtx, {
449
558
  role,
450
- route: resolveConfiguredTaskRoute(launchCtx, SUBAGENT_TASK),
559
+ route: modelClass === undefined
560
+ ? resolveConfiguredTaskRoute(launchCtx, SUBAGENT_TASK)
561
+ : resolveTaskRoute(launchCtx, modelClass),
451
562
  });
452
563
  },
453
564
  startWidget: startWidgetItem,
454
565
  updateWidgetTokens,
566
+ updateWidgetActivity,
455
567
  finishWidget: finishWidgetItem,
456
568
  });
457
569
 
@@ -463,24 +575,24 @@ export default function subagentExtension(
463
575
  promptGuidelines: [
464
576
  "Call delegate_task with exactly one mode: role+task for one task, tasks for 1–8 independent parallel tasks, or chain for 1–8 dependent sequential tasks using {previous} for the immediately preceding assistant output.",
465
577
  "Every delegate_task entry must state its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation; never pass the parent request unchanged.",
466
- "For each delegate_task entry, use fav only when the user asks for their favorite model; otherwise choose fast for narrow work, balanced for normal work, and frontier only for ambiguous, cross-cutting, or high-risk work.",
578
+ "For each delegate_task entry, populate model and thinking only for an explicit user override; otherwise choose only modelClass: fast normally, or balanced upfront for obviously complex work. This is Main policy, not runtime enforcement.",
467
579
  "Parallel delegate_task entries must own non-overlapping files. Keep integration and cross-cutting decisions in Main, and use the minimum number of Subagents needed.",
468
580
  "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.",
469
581
  ],
470
582
  parameters: WorkflowSchema,
471
- renderResult(result, _options, theme, _context) {
583
+ renderShell: "self",
584
+ renderCall(args, theme, _context) {
585
+ return renderToolLines([theme.fg("toolTitle", workflowCallLabel(args))], theme);
586
+ },
587
+ renderResult(result, { isPartial }, theme, _context) {
588
+ if (isPartial) return renderToolLines([], theme);
472
589
  const details = result.details;
473
- if (isWorkflowTransportDetails(details)) {
474
- return {
475
- invalidate() {},
476
- render: (width) => renderWorkflowResult(details, width, theme),
477
- };
478
- }
590
+ if (isWorkflowTransportDetails(details)) return renderToolLines(workflowResultLines(details, theme), theme);
479
591
  if (typeof details === "object" && details !== null && (details as { background?: unknown }).background === true) {
480
- return { invalidate() {}, render: (width) => wrapTextWithAnsi(theme.fg("muted", "Background workflow accepted."), Math.max(1, width)) };
592
+ return renderToolLines([theme.fg("muted", "Background workflow accepted.")], theme);
481
593
  }
482
594
  const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
483
- return { invalidate() {}, render: (width) => wrapTextWithAnsi(theme.fg("muted", text), Math.max(1, width)) };
595
+ return renderToolLines([theme.fg("muted", text)], theme);
484
596
  },
485
597
  prepareArguments(args) {
486
598
  try {
@@ -591,6 +703,7 @@ export default function subagentExtension(
591
703
  emitUpdate(emitToolUpdates);
592
704
  },
593
705
  onTokens: (tokens) => updateWidgetTokens(entry.id, tokens),
706
+ onActivity: (event) => updateWidgetActivity(entry.id, event),
594
707
  prepare: async () => {
595
708
  // Route and effective Role resources resolve only after this entry's
596
709
  // shared executor permit, before isolated state is created.
@@ -0,0 +1,16 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ export const MAX_RENDERED_RESULT_LINES = 3;
5
+
6
+ export function renderToolLines(lines: readonly string[], theme: Theme): Component {
7
+ return {
8
+ invalidate() {},
9
+ render: (width) => {
10
+ const shown = lines.length > MAX_RENDERED_RESULT_LINES
11
+ ? [...lines.slice(0, MAX_RENDERED_RESULT_LINES - 1), theme.fg("muted", `… ${lines.length - MAX_RENDERED_RESULT_LINES + 1} more`)]
12
+ : lines;
13
+ return shown.map((line) => truncateToWidth(line.replace(/[\r\n]+/g, " "), width));
14
+ },
15
+ };
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "5.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "Delegate bounded single, parallel, or chained tasks to isolated Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -66,6 +66,6 @@
66
66
  "dependencies": {
67
67
  "@henryqw/pi-herdr": "^0.4.0",
68
68
  "@henryqw/pi-multi-codex": "^0.3.8",
69
- "@henryqw/pi-task-models": "^1.0.0"
69
+ "@henryqw/pi-task-models": "^2.0.0"
70
70
  }
71
71
  }
@@ -5,28 +5,28 @@ description: Run bounded independent implementation through the runtime-managed
5
5
 
6
6
  # Delegated Development
7
7
 
8
- You are Main: slice work and call `delegate_flow`. Do not implement child work yourself or use external model tools, push, publish, or release.
8
+ You are Main, the planner/orchestrator: slice work and call `delegate_flow`. Do not implement child work yourself or use external model tools, push, publish, or release.
9
9
 
10
10
  ## Slice
11
11
 
12
12
  Use the fewest cohesive units. `delegate_flow` is for independent units expected to commute; combine or sequence work that overlaps files, APIs, schemas, generated output, package metadata, lockfiles, or invariants. Dependent work remains outside Flow, in one task or ordinary caller-controlled sequencing.
13
13
 
14
- Give every unit a bounded objective, owned scope and exclusions, acceptance criteria, and its direct validation command/argument array. Do not pass the parent request unchanged. Call `delegate_flow` with 1–8 units; the runtime supplies the effective Implementer and Reviewer Roles.
14
+ Give every unit a bounded objective, owned scope and exclusions, and its direct validation command/argument array. Do not pass the parent request unchanged. Use `modelClass: "fast"` normally; use `"balanced"` upfront only for obviously complex work. 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.
15
15
 
16
16
  ## Runtime Flow
17
17
 
18
- The runtime owns the unit worktrees and all Git identity, rebasing, declared validation, exact read-only review, fast-forward integration, and cleanup. It records the clean committed Main state, runs independent Implementers in isolated unit worktrees, processes units in declared order, and integrates only the exact reviewed tip OID.
18
+ The runtime owns the unit worktrees and all Git identity, rebasing, committed-state inspection, declared validation, conditional exact read-only review, fast-forward integration, and cleanup. Declared validation is the authority for objective verification. A validated unit without `review` skips review evidence and Reviewer launch, then integrates its exact validated tip. A unit with `review` receives the existing exact `{base, tip, patchPath}` protocol and must receive exactly `PASS` before integration.
19
19
 
20
20
  Trust the structured Flow outcome. Never edit a child worktree, manage its branches, prepare review evidence, reimplement Flow, or manually integrate its changes. Do not repeat Flow validation after it has completed or integrated a unit.
21
21
 
22
- A successful Flow owns integration and cleanup. A blocked outcome is repairable: provide one explicit continuation and no more:
22
+ A successful Flow owns integration and cleanup. A blocked outcome is repairable once: provide one explicit continuation and no more:
23
23
 
24
24
  ```ts
25
- delegate_flow_continue({ guidance: "Address the reported block and complete the bounded unit." })
25
+ delegate_flow_continue({ guidance: "Address the reported block and complete the bounded unit.", modelClass: "balanced" })
26
26
  ```
27
27
 
28
- Make the guidance specific to the reported implementation, validation, or review failure. 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.
28
+ Make the guidance specific to the reported implementation, validation, or review failure. Omit `modelClass` to retain the blocked unit's current class; supply it only to replace that one repair's class. 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.
29
29
 
30
30
  ## Ordinary delegation
31
31
 
32
- Use `delegate_task` for a single bounded task, independent parallel tasks, or dependent chain work that is not a Flow. Give each entry its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and focused validation. Preserve ordinary Role selection and isolation behavior. Keep integration and cross-cutting decisions in Main, and use the minimum number of Subagents needed.
32
+ Use `delegate_task` for a single bounded task, independent parallel tasks, or dependent chain work that is not a Flow. Give each entry its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and focused validation. Populate direct `model` and `thinking` only when the user explicitly asks for those overrides; otherwise choose only `modelClass` (`fast` normally, `balanced` upfront for obviously complex work). Keep integration and cross-cutting decisions in Main, and use the minimum number of Subagents needed.