@henryqw/pi-subagent 6.0.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,6 +30,8 @@ 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 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
+
33
35
  ### `delegate_task`
34
36
 
35
37
  Select exactly one shape:
@@ -51,7 +53,7 @@ Parallel mode starts entries concurrently, waits for every entry, and reports th
51
53
 
52
54
  Background workflows are session-scoped. Session shutdown or reload aborts them and may deliver only recoverable-work evidence or no follow-up message.
53
55
 
54
- The transient status widget owns live progress: spinner or terminal state, wrapped task summary, model, thinking level, tokens, and duration; 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.
55
57
 
56
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.
57
59
 
@@ -74,6 +76,18 @@ A rebase that drops all unit commits is a no-op: Flow validates it, skips Review
74
76
 
75
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.
76
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
+
77
91
  ## Config
78
92
 
79
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`.
@@ -8,10 +8,23 @@ export interface EphemeralSubagentExecutorOptions {
8
8
  maxConcurrency: number;
9
9
  timeout: EphemeralSubagentTimeout;
10
10
  }
11
+ export type EphemeralSubagentActivityEvent = {
12
+ type: "tool_execution_start";
13
+ toolCallId: string;
14
+ toolName: string;
15
+ path?: string;
16
+ } | {
17
+ type: "tool_execution_end";
18
+ toolCallId: string;
19
+ toolName: string;
20
+ } | {
21
+ type: "message_end";
22
+ };
11
23
  export interface EphemeralSubagentRunInput {
12
24
  signal?: AbortSignal;
13
25
  onUpdate?: (text: string) => void;
14
26
  onTokens?: (tokens: number) => void;
27
+ onActivity?: (event: EphemeralSubagentActivityEvent) => void;
15
28
  prepare: () => Promise<{
16
29
  launch: PiLaunch;
17
30
  task: string;
package/dist/ephemeral.js CHANGED
@@ -4,6 +4,9 @@ import { basename } from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
5
  const MAX_OUTPUT_BYTES = 50 * 1024;
6
6
  const MAX_JSON_EVENT_BYTES = 1024 * 1024;
7
+ const MAX_ACTIVITY_TEXT_BYTES = 4 * 1024;
8
+ // A JSON string byte can take six source bytes (for example, \u0000).
9
+ const MAX_ACTIVITY_PREFIX_BYTES = 2 * MAX_ACTIVITY_TEXT_BYTES * 6 + 1024;
7
10
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
8
11
  const POST_EXIT_STDIO_IDLE_MS = 250;
9
12
  const POST_EXIT_STDIO_HARD_MS = 1_000;
@@ -34,6 +37,8 @@ const PI_JSON_EVENTS = {
34
37
  };
35
38
  const CONSUMED_JSON_EVENTS = new Set(["message_start", "message_update", "message_end"]);
36
39
  const JSON_EVENT_TYPE = /^\s*\{\s*"type"\s*:\s*"([^"\\]+)"/;
40
+ const JSON_STRING = `"(?:[^"\\\\\u0000-\u001f]|\\\\(?:["\\\\/bfnrt]|u[0-9a-fA-F]{4}))*"`;
41
+ const JSON_OVERSIZED_TOOL_START = new RegExp(`^\\s*\\{\\s*"type"\\s*:\\s*"tool_execution_start"\\s*,\\s*"toolCallId"\\s*:\\s*(${JSON_STRING})\\s*,\\s*"toolName"\\s*:\\s*(${JSON_STRING})(?=\\s*,)`);
37
42
  export class EphemeralSubagentError extends Error {
38
43
  name = "EphemeralSubagentError";
39
44
  code;
@@ -85,11 +90,15 @@ function validateRunInput(value) {
85
90
  if (input.onTokens !== undefined && typeof input.onTokens !== "function") {
86
91
  throw new TypeError("run.onTokens must be a function.");
87
92
  }
93
+ if (input.onActivity !== undefined && typeof input.onActivity !== "function") {
94
+ throw new TypeError("run.onActivity must be a function.");
95
+ }
88
96
  return {
89
97
  signal: input.signal,
90
98
  prepare: input.prepare,
91
99
  onUpdate: input.onUpdate,
92
100
  onTokens: input.onTokens,
101
+ onActivity: input.onActivity,
93
102
  };
94
103
  }
95
104
  function record(value, field) {
@@ -229,6 +238,33 @@ function assistantText(message) {
229
238
  .join("\n");
230
239
  return text || undefined;
231
240
  }
241
+ function activityTooLong(value) {
242
+ return typeof value === "string" && Buffer.byteLength(value, "utf8") > MAX_ACTIVITY_TEXT_BYTES;
243
+ }
244
+ function hasTerminalControlChars(text) {
245
+ return Array.from(text).some((character) => {
246
+ const code = character.codePointAt(0);
247
+ return code <= 0x1f || code >= 0x7f && code <= 0x9f || code === 0x2028 || code === 0x2029;
248
+ });
249
+ }
250
+ function activityText(value) {
251
+ return typeof value === "string" && value.trim().length > 0 && !activityTooLong(value) && !hasTerminalControlChars(value);
252
+ }
253
+ function oversizedToolStart(prefix) {
254
+ const match = JSON_OVERSIZED_TOOL_START.exec(prefix);
255
+ if (!match)
256
+ return;
257
+ try {
258
+ const toolCallId = JSON.parse(match[1]);
259
+ const toolName = JSON.parse(match[2]);
260
+ if (!activityText(toolCallId) || !activityText(toolName))
261
+ return;
262
+ return { type: "tool_execution_start", toolCallId, toolName };
263
+ }
264
+ catch {
265
+ return;
266
+ }
267
+ }
232
268
  function utf8Prefix(text, maxBytes) {
233
269
  return new StringDecoder().write(Buffer.from(text).subarray(0, maxBytes));
234
270
  }
@@ -471,16 +507,26 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
471
507
  signalCallbackFailure();
472
508
  stop(true);
473
509
  };
510
+ let activityQueue = Promise.resolve();
474
511
  const invokeCallback = (name, callback, value) => {
475
512
  if (!callback || callbackFailure)
476
513
  return;
477
514
  let pending;
478
- try {
479
- pending = Promise.resolve(callback(value)).then(undefined, (cause) => { failCallback(name, cause); });
515
+ if (name === "onActivity") {
516
+ pending = activityQueue.then(() => {
517
+ if (!callbackFailure)
518
+ return callback(value);
519
+ }).catch((cause) => { failCallback(name, cause); });
520
+ activityQueue = pending;
480
521
  }
481
- catch (cause) {
482
- failCallback(name, cause);
483
- return;
522
+ else {
523
+ try {
524
+ pending = Promise.resolve(callback(value)).then(undefined, (cause) => { failCallback(name, cause); });
525
+ }
526
+ catch (cause) {
527
+ failCallback(name, cause);
528
+ return;
529
+ }
484
530
  }
485
531
  pendingCallbacks.add(pending);
486
532
  void pending.then(() => pendingCallbacks.delete(pending));
@@ -550,6 +596,29 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
550
596
  }
551
597
  return;
552
598
  }
599
+ if (record.type === "tool_execution_start" || record.type === "tool_execution_end") {
600
+ const { toolCallId, toolName } = record;
601
+ if (!activityText(toolCallId) || !activityText(toolName))
602
+ return;
603
+ if (record.type === "tool_execution_start") {
604
+ const args = record.args;
605
+ const path = args && typeof args === "object" && !Array.isArray(args)
606
+ ? args.path
607
+ : undefined;
608
+ if (activityTooLong(path))
609
+ return;
610
+ invokeCallback("onActivity", input.onActivity, {
611
+ type: "tool_execution_start",
612
+ toolCallId,
613
+ toolName,
614
+ ...(activityText(path) ? { path } : {}),
615
+ });
616
+ }
617
+ else {
618
+ invokeCallback("onActivity", input.onActivity, { type: "tool_execution_end", toolCallId, toolName });
619
+ }
620
+ return;
621
+ }
553
622
  if (record.type !== "message_end")
554
623
  return;
555
624
  const text = assistantText(record.message);
@@ -566,6 +635,7 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
566
635
  currentTokens = 0;
567
636
  currentUsage = undefined;
568
637
  invokeCallback("onTokens", input.onTokens, completedTokens);
638
+ invokeCallback("onActivity", input.onActivity, { type: "message_end" });
569
639
  }
570
640
  if (typeof message.stopReason === "string")
571
641
  stopReason = message.stopReason;
@@ -628,7 +698,9 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
628
698
  const end = newline === -1 ? data.length : newline;
629
699
  const part = data.slice(offset, end);
630
700
  if (!ignoreLine) {
631
- linePrefix += part.slice(0, Math.max(0, 256 - linePrefix.length));
701
+ const remainingPrefix = MAX_ACTIVITY_PREFIX_BYTES - Buffer.byteLength(linePrefix, "utf8");
702
+ if (remainingPrefix > 0)
703
+ linePrefix += utf8Prefix(part, remainingPrefix);
632
704
  const eventType = JSON_EVENT_TYPE.exec(linePrefix)?.[1];
633
705
  if (eventType && !lineEventType)
634
706
  lineEventType = eventType;
@@ -652,6 +724,13 @@ async function runPi(prepared, input, timeoutPolicy, invocation) {
652
724
  return;
653
725
  if (!ignoreLine)
654
726
  processLine(lineParts.join(""));
727
+ else if (lineEventType === "tool_execution_start") {
728
+ const activity = oversizedToolStart(linePrefix);
729
+ if (activity) {
730
+ observeEvent();
731
+ invokeCallback("onActivity", input.onActivity, activity);
732
+ }
733
+ }
655
734
  if (callbackFailure)
656
735
  return;
657
736
  lineParts = [];
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type HerdrExecutor } from "@henryqw/pi-herdr";
3
3
  import { type AvailableModel, type ProfileName, type ResolvedTaskRoute, type ThinkingLevel } from "@henryqw/pi-task-models";
4
- export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, EphemeralSubagentError, formatDuration, type EphemeralSubagentErrorCode, type EphemeralSubagentExecutor, type EphemeralSubagentExecutorOptions, type EphemeralSubagentResult, type EphemeralSubagentRunInput, type EphemeralSubagentTimeout, } from "./ephemeral.ts";
4
+ export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, EphemeralSubagentError, formatDuration, type EphemeralSubagentActivityEvent, type EphemeralSubagentErrorCode, type EphemeralSubagentExecutor, type EphemeralSubagentExecutorOptions, type EphemeralSubagentResult, type EphemeralSubagentRunInput, type EphemeralSubagentTimeout, } from "./ephemeral.ts";
5
5
  export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, worktreeContextNote, type WorktreeDirtyInspection, type WorktreeInfo, type WorktreePayload, } from "./worktree.ts";
6
6
  export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, type PreparedReviewEvidence, type PrepareExactReviewEvidenceInput, } from "./review-evidence.ts";
7
7
  export declare const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
@@ -165,7 +165,7 @@ const executorOptions = {
165
165
  };
166
166
  ```
167
167
 
168
- Concurrency is FIFO. `run` accepts optional `signal`, `onUpdate(text)`, and `onTokens(number)` callbacks plus required `prepare()`. A queued run receives its permit before `prepare` executes, so resource and route resolution can use the latest Pi state. Queued time does not consume child timeout. `maxConcurrency`, `idleMs`, and `maxMs` must be positive; `maxMs` must exceed `idleMs`.
168
+ Concurrency is FIFO. `run` accepts optional `signal`, `onUpdate(text)`, `onTokens(number)`, and `onActivity(event)` callbacks plus required `prepare()`. A queued run receives its permit before `prepare` executes, so resource and route resolution can use the latest Pi state. Queued time does not consume child timeout. `maxConcurrency`, `idleMs`, and `maxMs` must be positive; `maxMs` must exceed `idleMs`.
169
169
 
170
170
  The executor is **active-Pi-only**. It reuses the currently running Pi invocation and does not locate or support a standalone Node.js Pi installation. Once direct Pi exits, stdout/stderr drain normally until EOF; an escaped descendant retaining either stream is cut off after short output inactivity or a one-second hard deadline so it cannot retain the FIFO permit.
171
171
 
@@ -231,6 +231,18 @@ async function runRole(role, task, options = {}) {
231
231
 
232
232
  `run` resolves to `EphemeralSubagentResult`. Both outcome variants contain `exitCode`, `output`, `stderr`, and optional `stopReason`, `errorMessage`, and `usage`. A launched child/model failure is a typed `{ outcome: "failure", ... }` result. Abort, timeout, spawn, protocol, preparation, and callback failures reject with `EphemeralSubagentError` and a stable `code`. Assistant `output` and `stderr` are bounded, and `usage` contains aggregate child usage when Pi supplies it.
233
233
 
234
+ ### Activity callbacks
235
+
236
+ The optional `onActivity` callback receives structured activity events serially in child JSON-event order. This ordering applies only to `onActivity`; `onUpdate` and `onTokens` remain independent. A thrown or rejected activity callback fails the run with an `EphemeralSubagentError` whose code is `callback`.
237
+
238
+ | Event type | Fields |
239
+ | --- | --- |
240
+ | `tool_execution_start` | `toolCallId: string`, `toolName: string`, `path?: string` |
241
+ | `tool_execution_end` | `toolCallId: string`, `toolName: string` |
242
+ | `message_end` | none |
243
+
244
+ Activity text is limited to 4 KiB per field. An invalid `toolCallId` or `toolName`, or an oversized `path`, drops the event. A blank path or one containing C0/C1 terminal controls or Unicode line/paragraph separators is omitted from an otherwise valid start event.
245
+
234
246
  The low-level executor does not interpret `Role.isolation`, discover resources, compose modes, create shared state, or promote child failure outcomes to tool errors. A direct caller that wants worktrees must call `createChildWorktree` after the permit, choose the returned `cwd`, call `finalizeChildWorktree` on every exit path, and preserve its recovery payload.
235
247
 
236
248
  Generic managed Herdr exports (`managedSubagentWorkspaceId`, reconciliation helpers, `startManagedSubagent`, prompting/listing, and retirement) consume the same launch policy for durable workers. They intentionally contain no workflow prompts, semantic state, or retry policy.
@@ -10,6 +10,7 @@ import {
10
10
  inspectWorktreeDirty,
11
11
  prepareExactReviewEvidence,
12
12
  WorktreeSetupError,
13
+ type EphemeralSubagentActivityEvent,
13
14
  type EphemeralSubagentExecutor,
14
15
  type EphemeralSubagentResult,
15
16
  type ResolvedRoleLaunch,
@@ -19,6 +20,7 @@ import {
19
20
  import { Type, type Static } from "typebox";
20
21
  import { Check } from "typebox/value";
21
22
  import { runDelegation } from "./delegation.ts";
23
+ import { renderToolLines } from "./tool-render.ts";
22
24
 
23
25
  const MAX_UNITS = 8;
24
26
  const GIT_TIMEOUT_MS = 30_000;
@@ -106,6 +108,7 @@ type FlowState = {
106
108
  };
107
109
 
108
110
  type UsageMeter = { usage?: Usage };
111
+ type FlowProgress = { line: string };
109
112
 
110
113
  type CommandResult = {
111
114
  stdout: string;
@@ -129,6 +132,7 @@ export interface DelegateFlowRuntime {
129
132
  ctx: ExtensionContext,
130
133
  ) => void;
131
134
  updateWidgetTokens: (id: string, tokens: number) => void;
135
+ updateWidgetActivity: (id: string, event: EphemeralSubagentActivityEvent) => void;
132
136
  finishWidget: (id: string, status: WidgetStatus) => void;
133
137
  }
134
138
 
@@ -173,6 +177,44 @@ export function parseDelegateFlowContinue(value: unknown): Static<typeof Delegat
173
177
  };
174
178
  }
175
179
 
180
+ function unitCount(count: number): string {
181
+ return `${count} unit${count === 1 ? "" : "s"}`;
182
+ }
183
+
184
+ function flowCallLabel(args: { units?: unknown }): string {
185
+ const count = Array.isArray(args.units) ? args.units.length : 0;
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";
192
+ }
193
+
194
+ function flowResultLines(text: string): string[] {
195
+ const lines = text.split(/\r?\n/).filter((line) => line.trim());
196
+ const diagnosticHeader = lines.findIndex((line) => line.trim() === "Diagnostic:");
197
+ const diagnostic = diagnosticHeader === -1 ? undefined : lines[diagnosticHeader + 1];
198
+ const recoveryHeader = lines.findIndex((line) => line.trim() === "Retained Flow state:" || line.trim() === "Attempted allocations preserved without cleanup:");
199
+ const recovery = recoveryHeader === -1 || !lines[recoveryHeader + 1]?.trim().startsWith("- unit=")
200
+ ? undefined
201
+ : lines[recoveryHeader + 1];
202
+ if (diagnostic === undefined) {
203
+ if (recovery === undefined) return lines;
204
+ const recoveryIndex = lines.indexOf(recovery);
205
+ return [lines[0]!, recovery, ...lines.filter((_, index) => index !== 0 && index !== recoveryIndex)];
206
+ }
207
+ const leading = lines.slice(0, Math.min(1, diagnosticHeader));
208
+ if (recovery !== undefined) return [...leading, `Diagnostic: ${diagnostic}`, recovery];
209
+ // Promote the first diagnostic ahead of the result cap.
210
+ return [
211
+ ...leading,
212
+ `Diagnostic: ${diagnostic}`,
213
+ ...lines.slice(leading.length, diagnosticHeader),
214
+ ...lines.slice(diagnosticHeader + 2),
215
+ ];
216
+ }
217
+
176
218
  function errorText(error: unknown): string {
177
219
  return capOutput(error instanceof Error ? error.message : String(error));
178
220
  }
@@ -336,6 +378,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
336
378
  role: Role,
337
379
  modelClass: FlowModelClass,
338
380
  task: string,
381
+ widgetTask: string,
339
382
  cwd: string,
340
383
  widgetId: string,
341
384
  signal: AbortSignal | undefined,
@@ -348,13 +391,14 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
348
391
  const result = await runDelegation(runtime.executor, {
349
392
  signal,
350
393
  onTokens: (tokens) => runtime.updateWidgetTokens(widgetId, tokens),
394
+ onActivity: (event) => runtime.updateWidgetActivity(widgetId, event),
351
395
  prepare: async () => {
352
396
  assertCurrent(flow);
353
397
  const launch = runtime.resolveLaunch(role, modelClass, ctx);
354
398
  if (launch.missingSkills.length) {
355
399
  ctx.ui.notify(`Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`, "warning");
356
400
  }
357
- runtime.startWidget(widgetId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
401
+ runtime.startWidget(widgetId, role.name, launch.model.id, launch.thinkingLevel, widgetTask, ctx);
358
402
  started = true;
359
403
  return { launch, task, cwd };
360
404
  },
@@ -464,6 +508,14 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
464
508
  const lines = [
465
509
  `Flow ${outcome}.`,
466
510
  flow.completed.length ? `Completed units: ${flow.completed.map(({ id, noOp }) => `${JSON.stringify(id)}${noOp ? " (no-op)" : ""}`).join(", ")}` : "Completed units: none.",
511
+ ...(flow.setupRecoveries.length ? [
512
+ "Attempted allocations preserved without cleanup:",
513
+ ...flow.setupRecoveries.map((recovery) => `- unit=${JSON.stringify(recovery.id)} path=${JSON.stringify(recovery.path)} branch=${JSON.stringify(recovery.branch)} base=${recovery.base}`),
514
+ ] : []),
515
+ ...(retainedUnits.length ? [
516
+ "Retained Flow state:",
517
+ ...retainedUnits.map((unit) => `- unit=${JSON.stringify(unit.id)} path=${JSON.stringify(unit.path)} branch=${JSON.stringify(unit.branch)} base=${unit.base} worktree=${unit.worktreeRetained} branch_ref=${unit.branchRetained}`),
518
+ ] : []),
467
519
  ...(blocked ? [
468
520
  `Blocked unit: ${JSON.stringify(blocked.unit.request.id)}.`,
469
521
  `Classification: ${blocked.classification}.`,
@@ -473,14 +525,6 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
473
525
  ] : []),
474
526
  ...(failure ? [`Classification: ${failure.classification}.`, `Diagnostic:\n${failure.diagnostic}`] : []),
475
527
  ...(flow.warnings.length ? ["Warnings:", ...flow.warnings.map((warning) => `- ${warning}`)] : []),
476
- ...(flow.setupRecoveries.length ? [
477
- "Attempted allocations preserved without cleanup:",
478
- ...flow.setupRecoveries.map((recovery) => `- unit=${JSON.stringify(recovery.id)} path=${JSON.stringify(recovery.path)} branch=${JSON.stringify(recovery.branch)} base=${recovery.base}`),
479
- ] : []),
480
- ...(retainedUnits.length ? [
481
- "Retained Flow state:",
482
- ...retainedUnits.map((unit) => `- unit=${JSON.stringify(unit.id)} path=${JSON.stringify(unit.path)} branch=${JSON.stringify(unit.branch)} base=${unit.base} worktree=${unit.worktreeRetained} branch_ref=${unit.branchRetained}`),
483
- ] : []),
484
528
  ];
485
529
  return {
486
530
  content: [{ type: "text" as const, text: capOutput(lines.join("\n")) }],
@@ -575,6 +619,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
575
619
  signal: AbortSignal | undefined,
576
620
  ctx: ExtensionContext,
577
621
  meter: UsageMeter,
622
+ emitProgress: (line: string) => void,
578
623
  ) => {
579
624
  assertCurrent(flow);
580
625
  const main = flow.main!;
@@ -590,6 +635,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
590
635
 
591
636
  const implementationFailure = settlementFailure(unit.implementation!);
592
637
  if (implementationFailure) return block(flow, unit, "implementer", implementationFailure, meter);
638
+ emitProgress(`verify/integrate · unit ${flow.index + 1}/${flow.units.length}`);
593
639
 
594
640
  let inspected = await inspectUnit(unit, false, signal);
595
641
  assertCurrent(flow);
@@ -656,6 +702,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
656
702
  if (reviewCriterion !== undefined) {
657
703
  const reviewer = flow.reviewer;
658
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}`);
659
706
  let evidence;
660
707
  try {
661
708
  evidence = await prepareExactReviewEvidence({ base: main.expectedHead, tip, worktree: unit.worktree.path }, signal);
@@ -672,6 +719,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
672
719
  reviewer,
673
720
  unit.modelClass,
674
721
  reviewerTask(unit.request, reviewCriterion, { base: evidence.base, tip: evidence.tip, patchPath: evidence.patchPath }),
722
+ unit.request.task,
675
723
  unit.worktree.cwd,
676
724
  `${toolCallId}:flow:${flow.index}:review`,
677
725
  signal,
@@ -752,8 +800,16 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
752
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.",
753
801
  ],
754
802
  parameters: DelegateFlowSchema,
803
+ renderCall(args, theme, _context) {
804
+ return renderToolLines([theme.fg("toolTitle", flowCallLabel(args))], theme);
805
+ },
806
+ renderResult(result, { isPartial }, theme, _context) {
807
+ if (isPartial) return renderToolLines(isFlowProgress(result.details) ? [theme.fg("muted", result.details.line)] : [], theme);
808
+ const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
809
+ return renderToolLines(flowResultLines(text), theme);
810
+ },
755
811
  prepareArguments: parseDelegateFlow,
756
- async execute(toolCallId, params, signal, _onUpdate, ctx) {
812
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
757
813
  const request = parseDelegateFlow(params);
758
814
  if (active) throw new Error("delegate_flow rejected because another Flow is active.");
759
815
  const roles = runtime.loadRoles();
@@ -762,6 +818,10 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
762
818
  const reviewer = needsReviewer ? roles.find(({ name }) => name === "reviewer") : undefined;
763
819
  if (!implementer) throw new Error("delegate_flow requires an implementer Role.");
764
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
+ };
765
825
  const flow: FlowState = {
766
826
  phase: "running",
767
827
  generation: runtime.getSessionGeneration(),
@@ -775,6 +835,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
775
835
  warnings: [],
776
836
  };
777
837
  active = flow;
838
+ emitProgress(`setup · ${unitCount(request.units.length)}`);
778
839
  const operationSignal = bindSignal(flow, signal);
779
840
  const meter: UsageMeter = {};
780
841
  let setupComplete = false;
@@ -812,21 +873,27 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
812
873
  await checkMain(flow.main, operationSignal);
813
874
  assertCurrent(flow);
814
875
  setupComplete = true;
876
+ let completedImplementers = 0;
815
877
  const settlements = await Promise.all(flow.units.map((unit, index) => runChild(
816
878
  flow,
817
879
  flow.implementer,
818
880
  unit.modelClass,
819
881
  implementerTask(unit.request),
882
+ unit.request.task,
820
883
  unit.worktree.cwd,
821
884
  `${toolCallId}:flow:${index}:implement`,
822
885
  operationSignal,
823
886
  ctx,
824
887
  meter,
825
- )));
888
+ ).then((settlement) => {
889
+ completedImplementers += 1;
890
+ emitProgress(`implement · ${completedImplementers}/${flow.units.length} complete`);
891
+ return settlement;
892
+ })));
826
893
  for (const [index, settlement] of settlements.entries()) flow.units[index]!.implementation = settlement;
827
894
  assertCurrent(flow);
828
895
  if (operationSignal.aborted) operationSignal.throwIfAborted();
829
- return await processFlow(flow, toolCallId, operationSignal, ctx, meter);
896
+ return await processFlow(flow, toolCallId, operationSignal, ctx, meter, emitProgress);
830
897
  } catch (error) {
831
898
  if (!setupComplete) {
832
899
  for (const unit of [...flow.units].reverse()) {
@@ -848,8 +915,16 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
848
915
  promptSnippet: "Repair and continue the blocked deterministic Flow",
849
916
  promptGuidelines: ["Call delegate_flow_continue only after delegate_flow reports a repairable block, with explicit guidance addressing that block."],
850
917
  parameters: DelegateFlowContinueSchema,
918
+ renderCall(_args, theme, _context) {
919
+ return renderToolLines([theme.fg("toolTitle", "delegate_flow_continue · repair continuation")], theme);
920
+ },
921
+ renderResult(result, { isPartial }, theme, _context) {
922
+ if (isPartial) return renderToolLines(isFlowProgress(result.details) ? [theme.fg("muted", result.details.line)] : [], theme);
923
+ const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
924
+ return renderToolLines(flowResultLines(text), theme);
925
+ },
851
926
  prepareArguments: parseDelegateFlowContinue,
852
- async execute(toolCallId, params, signal, _onUpdate, ctx) {
927
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
853
928
  const { guidance, modelClass } = parseDelegateFlowContinue(params);
854
929
  const flow = active;
855
930
  if (!flow) throw new Error("delegate_flow_continue requires an active blocked Flow.");
@@ -864,12 +939,18 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
864
939
  if (modelClass !== undefined) unit.modelClass = modelClass;
865
940
  const operationSignal = bindSignal(flow, signal);
866
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
+ };
867
946
  try {
947
+ emitProgress(`repair · unit ${flow.index + 1}/${flow.units.length}`);
868
948
  unit.implementation = await runChild(
869
949
  flow,
870
950
  flow.implementer,
871
951
  unit.modelClass,
872
952
  repairTask(unit.request, blocked, guidance),
953
+ unit.request.task,
873
954
  unit.worktree.cwd,
874
955
  `${toolCallId}:flow:${flow.index}:repair`,
875
956
  operationSignal,
@@ -878,7 +959,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
878
959
  );
879
960
  assertCurrent(flow);
880
961
  if (operationSignal.aborted) operationSignal.throwIfAborted();
881
- return await processFlow(flow, toolCallId, operationSignal, ctx, meter);
962
+ return await processFlow(flow, toolCallId, operationSignal, ctx, meter, emitProgress);
882
963
  } catch (error) {
883
964
  return terminal(flow, "infrastructure", errorText(error), meter);
884
965
  }
@@ -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,
@@ -54,7 +57,9 @@ import {
54
57
  const SUBAGENT_TASK = "pi-subagent/delegateTask";
55
58
  const WIDGET_KEY = "subagent-status";
56
59
  const WIDGET_INTERVAL_MS = 80;
57
- const MAX_WIDGET_ROWS = 8;
60
+ const MAX_WIDGET_ITEMS = 8;
61
+ const MAX_WIDGET_LINES = 6;
62
+ export const MAX_WIDGET_ACTIVE_TOOLS = 8;
58
63
  const DEFAULT_TIMEOUT_POLICY = {
59
64
  idleMs: DEFAULT_TIMEOUT_CONFIG.idleMinutes * 60_000,
60
65
  maxMs: DEFAULT_TIMEOUT_CONFIG.maxMinutes * 60_000,
@@ -71,6 +76,12 @@ export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined)
71
76
  };
72
77
  }
73
78
  type WidgetStatus = "working" | "success" | "failure" | "aborted";
79
+ type WidgetActiveTool = {
80
+ toolName: string;
81
+ path?: string;
82
+ startedAt: number;
83
+ order: number;
84
+ };
74
85
  type WidgetItem = {
75
86
  role: string;
76
87
  model: string;
@@ -80,6 +91,11 @@ type WidgetItem = {
80
91
  startedAt: number;
81
92
  status: WidgetStatus;
82
93
  finishedAt?: number;
94
+ completedAssistantTurns: number;
95
+ startedToolCount: number;
96
+ activeTools: Map<string, WidgetActiveTool>;
97
+ activeToolId?: string;
98
+ activityOrder: number;
83
99
  };
84
100
 
85
101
  function taskSummary(task: string): string {
@@ -111,6 +127,34 @@ function statusLabel(status: WidgetStatus): string {
111
127
  }
112
128
  }
113
129
 
130
+ function activityLabel(item: WidgetItem, now: number): string {
131
+ if (item.status === "success") return "Done";
132
+ if (item.status === "failure") return "Failed";
133
+ if (item.status === "aborted") return "Stopped";
134
+ const activeTool = item.activeToolId === undefined ? undefined : item.activeTools.get(item.activeToolId);
135
+ if (!activeTool) return "thinking…";
136
+ return [
137
+ activeTool.toolName,
138
+ formatDuration(now - activeTool.startedAt),
139
+ ...(activeTool.path === undefined ? [] : [activeTool.path]),
140
+ ].join(" · ");
141
+ }
142
+
143
+ function activityMetrics(item: WidgetItem, now: number): string {
144
+ return [
145
+ ...(item.completedAssistantTurns === 0
146
+ ? []
147
+ : [`${item.completedAssistantTurns} turn${item.completedAssistantTurns === 1 ? "" : "s"}`]),
148
+ ...(item.startedToolCount === 0
149
+ ? []
150
+ : [`${item.startedToolCount} tool${item.startedToolCount === 1 ? "" : "s"}`]),
151
+ item.model,
152
+ item.thinkingLevel,
153
+ `${formatTokens(item.tokens)} tok`,
154
+ formatDuration((item.finishedAt ?? now) - item.startedAt),
155
+ ].join(" · ");
156
+ }
157
+
114
158
  function isWorkflowTransportDetails(value: unknown): value is WorkflowTransportDetails {
115
159
  const isRecord = (candidate: unknown): candidate is Record<string, unknown> => typeof candidate === "object" && candidate !== null && !Array.isArray(candidate);
116
160
  const isOptionalString = (candidate: unknown) => candidate === undefined || typeof candidate === "string";
@@ -134,18 +178,55 @@ function isWorkflowTransportDetails(value: unknown): value is WorkflowTransportD
134
178
  && entries.every((entry, index) => index === 0 || (entry as { index: number }).index > (entries[index - 1] as { index: number }).index);
135
179
  }
136
180
 
137
- function renderWorkflowResult(details: WorkflowTransportDetails, width: number, theme: Theme): string[] {
181
+ function workflowCallLabel(args: { tasks?: unknown; chain?: unknown }): string {
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(" · ");
206
+ }
207
+
208
+ function workflowResultLines(details: WorkflowTransportDetails, theme: Theme): string[] {
138
209
  if (details.entries.some(({ status }) => status === "pending" || status === "running")) return [];
139
- return details.entries.flatMap((entry) => {
140
- if (entry.status === "skipped") return [];
210
+ const entries = details.entries.filter(({ status }) => status !== "skipped");
211
+ const withRecovery = (entry: typeof entries[0]) => entry.worktree && !entry.worktree.pruned;
212
+ const isTerminalFailure = (entry: typeof entries[0]) => entry.status === "failed" || entry.status === "rejected";
213
+ const sorted = [...entries].sort((a, b) => {
214
+ const aFailure = isTerminalFailure(a);
215
+ const bFailure = isTerminalFailure(b);
216
+ if (aFailure !== bFailure) return aFailure ? -1 : 1;
217
+ const aRecovery = !aFailure && withRecovery(a);
218
+ const bRecovery = !bFailure && withRecovery(b);
219
+ if (aRecovery !== bRecovery) return aRecovery ? -1 : 1;
220
+ return 0;
221
+ });
222
+ return sorted.map((entry) => {
141
223
  const summary = entry.summary || "(no output)";
142
224
  const text = details.mode === "single" ? summary : `${entry.role}: ${summary}`;
143
225
  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
- ];
226
+ const recovery = withRecovery(entry) ? `Recovery: ${entry.worktree!.path}` : undefined;
227
+ return recovery === undefined
228
+ ? theme.fg(style, text)
229
+ : `${theme.fg("warning", recovery)} · ${theme.fg(style, text)}`;
149
230
  });
150
231
  }
151
232
 
@@ -156,16 +237,23 @@ function renderWidgetRows(
156
237
  spinnerIndex: number,
157
238
  theme: Theme,
158
239
  ): string[] {
159
- 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);
160
242
  if (!visible.length) return [];
161
- const indent = " ".repeat(Math.min(2, Math.max(0, width - 1)));
162
- const contentWidth = Math.max(1, width - indent.length);
163
- 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}`),
167
- ]);
168
- if (items.length > visible.length) lines.push(...wrapTextWithAnsi(theme.fg("muted", `… ${items.length - visible.length} more`), Math.max(1, 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
+ }
169
257
  return lines;
170
258
  }
171
259
 
@@ -296,11 +384,11 @@ export default function subagentExtension(
296
384
  ) => {
297
385
  if (!ctx.hasUI) return;
298
386
  ensureWidget(ctx);
299
- if (!widgetItems.has(id) && widgetItems.size >= MAX_WIDGET_ROWS) {
387
+ if (!widgetItems.has(id) && widgetItems.size >= MAX_WIDGET_ITEMS) {
300
388
  for (const [oldestId, item] of widgetItems) {
301
389
  if (item.status === "working") continue;
302
390
  widgetItems.delete(oldestId);
303
- if (widgetItems.size < MAX_WIDGET_ROWS) break;
391
+ if (widgetItems.size < MAX_WIDGET_ITEMS) break;
304
392
  }
305
393
  }
306
394
  widgetItems.set(id, {
@@ -311,6 +399,10 @@ export default function subagentExtension(
311
399
  tokens: 0,
312
400
  startedAt: Date.now(),
313
401
  status: "working",
402
+ completedAssistantTurns: 0,
403
+ startedToolCount: 0,
404
+ activeTools: new Map(),
405
+ activityOrder: 0,
314
406
  });
315
407
  startWidgetTimer();
316
408
  requestWidgetRender();
@@ -323,11 +415,55 @@ export default function subagentExtension(
323
415
  requestWidgetRender();
324
416
  };
325
417
 
418
+ const updateWidgetActivity = (id: string, event: EphemeralSubagentActivityEvent) => {
419
+ const item = widgetItems.get(id);
420
+ if (!item || item.status !== "working") return;
421
+ switch (event.type) {
422
+ case "tool_execution_start": {
423
+ if (item.activeTools.has(event.toolCallId)) break;
424
+ if (item.activeTools.size >= MAX_WIDGET_ACTIVE_TOOLS) {
425
+ let oldest: [string, WidgetActiveTool] | undefined;
426
+ for (const candidate of item.activeTools) {
427
+ if (!oldest || candidate[1].order < oldest[1].order) oldest = candidate;
428
+ }
429
+ if (oldest) item.activeTools.delete(oldest[0]);
430
+ }
431
+ const path = event.path === undefined ? undefined : basename(event.path);
432
+ item.startedToolCount += 1;
433
+ item.activeTools.set(event.toolCallId, {
434
+ toolName: event.toolName,
435
+ ...(path ? { path } : {}),
436
+ startedAt: Date.now(),
437
+ order: ++item.activityOrder,
438
+ });
439
+ item.activeToolId = event.toolCallId;
440
+ break;
441
+ }
442
+ case "tool_execution_end": {
443
+ item.activeTools.delete(event.toolCallId);
444
+ if (item.activeToolId === event.toolCallId) {
445
+ let latest: [string, WidgetActiveTool] | undefined;
446
+ for (const candidate of item.activeTools) {
447
+ if (!latest || candidate[1].order > latest[1].order) latest = candidate;
448
+ }
449
+ item.activeToolId = latest?.[0];
450
+ }
451
+ break;
452
+ }
453
+ case "message_end":
454
+ item.completedAssistantTurns += 1;
455
+ break;
456
+ }
457
+ requestWidgetRender();
458
+ };
459
+
326
460
  const finishWidgetItem = (id: string, status: Exclude<WidgetStatus, "working">) => {
327
461
  const item = widgetItems.get(id);
328
462
  if (!item) return;
329
463
  item.status = status;
330
464
  item.finishedAt = Date.now();
465
+ item.activeTools.clear();
466
+ item.activeToolId = undefined;
331
467
  if (![...widgetItems.values()].some(({ status }) => status === "working")) stopWidgetTimer();
332
468
  requestWidgetRender();
333
469
  };
@@ -454,6 +590,7 @@ export default function subagentExtension(
454
590
  },
455
591
  startWidget: startWidgetItem,
456
592
  updateWidgetTokens,
593
+ updateWidgetActivity,
457
594
  finishWidget: finishWidgetItem,
458
595
  });
459
596
 
@@ -470,19 +607,20 @@ export default function subagentExtension(
470
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.",
471
608
  ],
472
609
  parameters: WorkflowSchema,
473
- renderResult(result, _options, theme, _context) {
610
+ renderCall(args, theme, _context) {
611
+ return renderToolLines([theme.fg("toolTitle", workflowCallLabel(args))], theme);
612
+ },
613
+ renderResult(result, { isPartial }, theme, _context) {
474
614
  const details = result.details;
475
- if (isWorkflowTransportDetails(details)) {
476
- return {
477
- invalidate() {},
478
- render: (width) => renderWorkflowResult(details, width, theme),
479
- };
480
- }
615
+ if (isPartial) return renderToolLines(isWorkflowTransportDetails(details)
616
+ ? [theme.fg("muted", workflowProgressLine(details))]
617
+ : [], theme);
618
+ if (isWorkflowTransportDetails(details)) return renderToolLines(workflowResultLines(details, theme), theme);
481
619
  if (typeof details === "object" && details !== null && (details as { background?: unknown }).background === true) {
482
- return { invalidate() {}, render: (width) => wrapTextWithAnsi(theme.fg("muted", "Background workflow accepted."), Math.max(1, width)) };
620
+ return renderToolLines([theme.fg("muted", "Background workflow accepted.")], theme);
483
621
  }
484
622
  const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
485
- return { invalidate() {}, render: (width) => wrapTextWithAnsi(theme.fg("muted", text), Math.max(1, width)) };
623
+ return renderToolLines([theme.fg("muted", text)], theme);
486
624
  },
487
625
  prepareArguments(args) {
488
626
  try {
@@ -593,6 +731,7 @@ export default function subagentExtension(
593
731
  emitUpdate(emitToolUpdates);
594
732
  },
595
733
  onTokens: (tokens) => updateWidgetTokens(entry.id, tokens),
734
+ onActivity: (event) => updateWidgetActivity(entry.id, event),
596
735
  prepare: async () => {
597
736
  // Route and effective Role resources resolve only after this entry's
598
737
  // 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": "6.0.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",