@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 +15 -1
- package/dist/ephemeral.d.ts +13 -0
- package/dist/ephemeral.js +85 -6
- package/dist/index.d.ts +1 -1
- package/docs/orchestration.md +13 -1
- package/extensions/delegate-flow.ts +95 -14
- package/extensions/subagent.ts +169 -30
- package/extensions/tool-render.ts +16 -0
- package/package.json +1 -1
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
|
|
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`.
|
package/dist/ephemeral.d.ts
CHANGED
|
@@ -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
|
-
|
|
479
|
-
pending =
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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";
|
package/docs/orchestration.md
CHANGED
|
@@ -165,7 +165,7 @@ const executorOptions = {
|
|
|
165
165
|
};
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
-
Concurrency is FIFO. `run` accepts optional `signal`, `onUpdate(text)`,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
}
|
package/extensions/subagent.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
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
|
|
145
|
-
return
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 >=
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|