@henryqw/pi-subagent 6.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.
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 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.
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 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.
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
 
@@ -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;
@@ -129,6 +131,7 @@ export interface DelegateFlowRuntime {
129
131
  ctx: ExtensionContext,
130
132
  ) => void;
131
133
  updateWidgetTokens: (id: string, tokens: number) => void;
134
+ updateWidgetActivity: (id: string, event: EphemeralSubagentActivityEvent) => void;
132
135
  finishWidget: (id: string, status: WidgetStatus) => void;
133
136
  }
134
137
 
@@ -173,6 +176,35 @@ export function parseDelegateFlowContinue(value: unknown): Static<typeof Delegat
173
176
  };
174
177
  }
175
178
 
179
+ function flowCallLabel(args: { units?: unknown }): string {
180
+ const count = Array.isArray(args.units) ? args.units.length : 0;
181
+ return `delegate_flow · working: ${count} unit${count === 1 ? "" : "s"}`;
182
+ }
183
+
184
+ function flowResultLines(text: string): string[] {
185
+ const lines = text.split(/\r?\n/).filter((line) => line.trim());
186
+ const diagnosticHeader = lines.findIndex((line) => line.trim() === "Diagnostic:");
187
+ const diagnostic = diagnosticHeader === -1 ? undefined : lines[diagnosticHeader + 1];
188
+ const recoveryHeader = lines.findIndex((line) => line.trim() === "Retained Flow state:" || line.trim() === "Attempted allocations preserved without cleanup:");
189
+ const recovery = recoveryHeader === -1 || !lines[recoveryHeader + 1]?.trim().startsWith("- unit=")
190
+ ? undefined
191
+ : lines[recoveryHeader + 1];
192
+ if (diagnostic === undefined) {
193
+ if (recovery === undefined) return lines;
194
+ const recoveryIndex = lines.indexOf(recovery);
195
+ return [lines[0]!, recovery, ...lines.filter((_, index) => index !== 0 && index !== recoveryIndex)];
196
+ }
197
+ const leading = lines.slice(0, Math.min(1, diagnosticHeader));
198
+ if (recovery !== undefined) return [...leading, `Diagnostic: ${diagnostic}`, recovery];
199
+ // Promote the first diagnostic ahead of the result cap.
200
+ return [
201
+ ...leading,
202
+ `Diagnostic: ${diagnostic}`,
203
+ ...lines.slice(leading.length, diagnosticHeader),
204
+ ...lines.slice(diagnosticHeader + 2),
205
+ ];
206
+ }
207
+
176
208
  function errorText(error: unknown): string {
177
209
  return capOutput(error instanceof Error ? error.message : String(error));
178
210
  }
@@ -336,6 +368,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
336
368
  role: Role,
337
369
  modelClass: FlowModelClass,
338
370
  task: string,
371
+ widgetTask: string,
339
372
  cwd: string,
340
373
  widgetId: string,
341
374
  signal: AbortSignal | undefined,
@@ -348,13 +381,14 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
348
381
  const result = await runDelegation(runtime.executor, {
349
382
  signal,
350
383
  onTokens: (tokens) => runtime.updateWidgetTokens(widgetId, tokens),
384
+ onActivity: (event) => runtime.updateWidgetActivity(widgetId, event),
351
385
  prepare: async () => {
352
386
  assertCurrent(flow);
353
387
  const launch = runtime.resolveLaunch(role, modelClass, ctx);
354
388
  if (launch.missingSkills.length) {
355
389
  ctx.ui.notify(`Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`, "warning");
356
390
  }
357
- runtime.startWidget(widgetId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
391
+ runtime.startWidget(widgetId, role.name, launch.model.id, launch.thinkingLevel, widgetTask, ctx);
358
392
  started = true;
359
393
  return { launch, task, cwd };
360
394
  },
@@ -464,6 +498,14 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
464
498
  const lines = [
465
499
  `Flow ${outcome}.`,
466
500
  flow.completed.length ? `Completed units: ${flow.completed.map(({ id, noOp }) => `${JSON.stringify(id)}${noOp ? " (no-op)" : ""}`).join(", ")}` : "Completed units: none.",
501
+ ...(flow.setupRecoveries.length ? [
502
+ "Attempted allocations preserved without cleanup:",
503
+ ...flow.setupRecoveries.map((recovery) => `- unit=${JSON.stringify(recovery.id)} path=${JSON.stringify(recovery.path)} branch=${JSON.stringify(recovery.branch)} base=${recovery.base}`),
504
+ ] : []),
505
+ ...(retainedUnits.length ? [
506
+ "Retained Flow state:",
507
+ ...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}`),
508
+ ] : []),
467
509
  ...(blocked ? [
468
510
  `Blocked unit: ${JSON.stringify(blocked.unit.request.id)}.`,
469
511
  `Classification: ${blocked.classification}.`,
@@ -473,14 +515,6 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
473
515
  ] : []),
474
516
  ...(failure ? [`Classification: ${failure.classification}.`, `Diagnostic:\n${failure.diagnostic}`] : []),
475
517
  ...(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
518
  ];
485
519
  return {
486
520
  content: [{ type: "text" as const, text: capOutput(lines.join("\n")) }],
@@ -672,6 +706,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
672
706
  reviewer,
673
707
  unit.modelClass,
674
708
  reviewerTask(unit.request, reviewCriterion, { base: evidence.base, tip: evidence.tip, patchPath: evidence.patchPath }),
709
+ unit.request.task,
675
710
  unit.worktree.cwd,
676
711
  `${toolCallId}:flow:${flow.index}:review`,
677
712
  signal,
@@ -752,6 +787,14 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
752
787
  "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
788
  ],
754
789
  parameters: DelegateFlowSchema,
790
+ renderShell: "self",
791
+ renderCall(args, theme, _context) {
792
+ return renderToolLines([theme.fg("toolTitle", flowCallLabel(args))], theme);
793
+ },
794
+ renderResult(result, _options, theme, _context) {
795
+ const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
796
+ return renderToolLines(flowResultLines(text), theme);
797
+ },
755
798
  prepareArguments: parseDelegateFlow,
756
799
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
757
800
  const request = parseDelegateFlow(params);
@@ -817,6 +860,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
817
860
  flow.implementer,
818
861
  unit.modelClass,
819
862
  implementerTask(unit.request),
863
+ unit.request.task,
820
864
  unit.worktree.cwd,
821
865
  `${toolCallId}:flow:${index}:implement`,
822
866
  operationSignal,
@@ -848,6 +892,14 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
848
892
  promptSnippet: "Repair and continue the blocked deterministic Flow",
849
893
  promptGuidelines: ["Call delegate_flow_continue only after delegate_flow reports a repairable block, with explicit guidance addressing that block."],
850
894
  parameters: DelegateFlowContinueSchema,
895
+ renderShell: "self",
896
+ renderCall(_args, theme, _context) {
897
+ return renderToolLines([theme.fg("toolTitle", "delegate_flow_continue · working: repair continuation")], theme);
898
+ },
899
+ renderResult(result, _options, theme, _context) {
900
+ const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
901
+ return renderToolLines(flowResultLines(text), theme);
902
+ },
851
903
  prepareArguments: parseDelegateFlowContinue,
852
904
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
853
905
  const { guidance, modelClass } = parseDelegateFlowContinue(params);
@@ -870,6 +922,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
870
922
  flow.implementer,
871
923
  unit.modelClass,
872
924
  repairTask(unit.request, blocked, guidance),
925
+ unit.request.task,
873
926
  unit.worktree.cwd,
874
927
  `${toolCallId}:flow:${flow.index}:repair`,
875
928
  operationSignal,
@@ -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
  };
@@ -454,6 +563,7 @@ export default function subagentExtension(
454
563
  },
455
564
  startWidget: startWidgetItem,
456
565
  updateWidgetTokens,
566
+ updateWidgetActivity,
457
567
  finishWidget: finishWidgetItem,
458
568
  });
459
569
 
@@ -470,19 +580,19 @@ export default function subagentExtension(
470
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.",
471
581
  ],
472
582
  parameters: WorkflowSchema,
473
- 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);
474
589
  const details = result.details;
475
- if (isWorkflowTransportDetails(details)) {
476
- return {
477
- invalidate() {},
478
- render: (width) => renderWorkflowResult(details, width, theme),
479
- };
480
- }
590
+ if (isWorkflowTransportDetails(details)) return renderToolLines(workflowResultLines(details, theme), theme);
481
591
  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)) };
592
+ return renderToolLines([theme.fg("muted", "Background workflow accepted.")], theme);
483
593
  }
484
594
  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)) };
595
+ return renderToolLines([theme.fg("muted", text)], theme);
486
596
  },
487
597
  prepareArguments(args) {
488
598
  try {
@@ -593,6 +703,7 @@ export default function subagentExtension(
593
703
  emitUpdate(emitToolUpdates);
594
704
  },
595
705
  onTokens: (tokens) => updateWidgetTokens(entry.id, tokens),
706
+ onActivity: (event) => updateWidgetActivity(entry.id, event),
596
707
  prepare: async () => {
597
708
  // Route and effective Role resources resolve only after this entry's
598
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": "6.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",