oira666_pi-subagent 0.2.22 → 0.2.24
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/index.ts +71 -1
- package/package.json +75 -74
- package/render.ts +87 -471
- package/runner.ts +15 -0
- package/tree.ts +475 -0
- package/types.ts +7 -0
package/index.ts
CHANGED
|
@@ -632,6 +632,73 @@ export default function (pi: ExtensionAPI) {
|
|
|
632
632
|
};
|
|
633
633
|
let nextActiveSubagentId = 1;
|
|
634
634
|
|
|
635
|
+
/**
|
|
636
|
+
* Build a lightweight version of SubagentDetails for live progress bubbling.
|
|
637
|
+
*
|
|
638
|
+
* Full message histories can be very large in long agent trees. For live TUI
|
|
639
|
+
* rendering we only need subagent tool-call structure, nested subagent
|
|
640
|
+
* results, live logs, metadata, and usage counters. Text conversations are
|
|
641
|
+
* intentionally omitted; final durable results still arrive via normal
|
|
642
|
+
* tool_result_end messages.
|
|
643
|
+
*/
|
|
644
|
+
function slimDetailsForProgress(details: SubagentDetails): SubagentDetails {
|
|
645
|
+
const slimResult = (result: SingleResult): SingleResult => {
|
|
646
|
+
const slimMessages = result.messages
|
|
647
|
+
.map((message: any) => {
|
|
648
|
+
if (message?.role === "assistant" && Array.isArray(message.content)) {
|
|
649
|
+
const subagentCalls = message.content.filter(
|
|
650
|
+
(part: any) => part?.type === "toolCall" && part?.name === "subagent",
|
|
651
|
+
);
|
|
652
|
+
return subagentCalls.length > 0
|
|
653
|
+
? { ...message, content: subagentCalls }
|
|
654
|
+
: null;
|
|
655
|
+
}
|
|
656
|
+
if (message?.role === "toolResult" && message.toolName === "subagent") {
|
|
657
|
+
return isSubagentDetails(message.details)
|
|
658
|
+
? { ...message, details: slimDetailsForProgress(message.details) }
|
|
659
|
+
: message;
|
|
660
|
+
}
|
|
661
|
+
return null;
|
|
662
|
+
})
|
|
663
|
+
.filter(Boolean) as SingleResult["messages"];
|
|
664
|
+
|
|
665
|
+
const liveNestedSubagents = result.liveNestedSubagents
|
|
666
|
+
? Object.fromEntries(
|
|
667
|
+
Object.entries(result.liveNestedSubagents).map(([nestedToolCallId, nested]) => [
|
|
668
|
+
nestedToolCallId,
|
|
669
|
+
slimDetailsForProgress(nested),
|
|
670
|
+
]),
|
|
671
|
+
)
|
|
672
|
+
: undefined;
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
...result,
|
|
676
|
+
messages: slimMessages,
|
|
677
|
+
stderr: result.stderr ? result.stderr.slice(-1000) : "",
|
|
678
|
+
liveLog: [...(result.liveLog ?? [])],
|
|
679
|
+
liveNestedSubagents,
|
|
680
|
+
};
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
...details,
|
|
685
|
+
results: details.results.map(slimResult),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function emitNestedProgressToParent(toolCallId: string, details: SubagentDetails): void {
|
|
690
|
+
if (currentDepth <= 0) return;
|
|
691
|
+
try {
|
|
692
|
+
process.stdout.write(`${JSON.stringify({
|
|
693
|
+
type: "subagent_progress",
|
|
694
|
+
toolCallId,
|
|
695
|
+
details: slimDetailsForProgress(details),
|
|
696
|
+
})}\n`);
|
|
697
|
+
} catch {
|
|
698
|
+
// Best-effort only. Normal final tool_result_end still carries the durable result.
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
635
702
|
const BROADCAST_STEER_PREFIX = "__PI_SUBAGENT_BROADCAST_STEER__";
|
|
636
703
|
|
|
637
704
|
interface BroadcastTarget {
|
|
@@ -1157,7 +1224,10 @@ calls one after another. Do NOT put dependent tasks in the same array.
|
|
|
1157
1224
|
|
|
1158
1225
|
const executionMode = tasks.length === 1 ? "single" : "parallel";
|
|
1159
1226
|
const trackedOnUpdate = (partial: any) => {
|
|
1160
|
-
if (isSubagentDetails(partial?.details))
|
|
1227
|
+
if (isSubagentDetails(partial?.details)) {
|
|
1228
|
+
updateLatestBroadcastTargets(partial.details);
|
|
1229
|
+
emitNestedProgressToParent(toolCallId, partial.details);
|
|
1230
|
+
}
|
|
1161
1231
|
onUpdate?.(partial);
|
|
1162
1232
|
};
|
|
1163
1233
|
|
package/package.json
CHANGED
|
@@ -1,74 +1,75 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "oira666_pi-subagent",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "index.ts",
|
|
7
|
-
"files": [
|
|
8
|
-
"index.ts",
|
|
9
|
-
"agents.ts",
|
|
10
|
-
"runner.ts",
|
|
11
|
-
"resume.ts",
|
|
12
|
-
"shared.ts",
|
|
13
|
-
"render.ts",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"@mariozechner/pi-
|
|
53
|
-
"@mariozechner/pi-
|
|
54
|
-
"@mariozechner/pi-
|
|
55
|
-
"@
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "oira666_pi-subagent",
|
|
3
|
+
"version": "0.2.24",
|
|
4
|
+
"description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"agents.ts",
|
|
10
|
+
"runner.ts",
|
|
11
|
+
"resume.ts",
|
|
12
|
+
"shared.ts",
|
|
13
|
+
"render.ts",
|
|
14
|
+
"tree.ts",
|
|
15
|
+
"types.ts",
|
|
16
|
+
"agents/*.md",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./index.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"pi",
|
|
27
|
+
"subagent",
|
|
28
|
+
"delegation",
|
|
29
|
+
"pi-package"
|
|
30
|
+
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/gee666/pi-subagent.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/gee666/pi-subagent/issues"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/gee666/pi-subagent#readme",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "node --import tsx/esm --test tests/*.test.ts test/*.test.ts"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^25.2.3",
|
|
48
|
+
"tsx": "^4.21.0",
|
|
49
|
+
"typescript": "^5.9.3"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@mariozechner/pi-agent-core": ">=0.37.0",
|
|
53
|
+
"@mariozechner/pi-ai": ">=0.37.0",
|
|
54
|
+
"@mariozechner/pi-coding-agent": ">=0.37.0",
|
|
55
|
+
"@mariozechner/pi-tui": ">=0.37.0",
|
|
56
|
+
"@sinclair/typebox": ">=0.34.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"@mariozechner/pi-agent-core": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"@mariozechner/pi-coding-agent": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@mariozechner/pi-tui": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@mariozechner/pi-ai": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"@sinclair/typebox": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
package/render.ts
CHANGED
|
@@ -1,471 +1,87 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TUI rendering for subagent tool calls and results.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
type
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function splitOutputLines(text: string): string[] {
|
|
91
|
-
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
92
|
-
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
93
|
-
return lines;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function lastNonEmptyLines(text: string, limit: number): string[] {
|
|
97
|
-
return splitOutputLines(text)
|
|
98
|
-
.map((line) => line.trimEnd())
|
|
99
|
-
.filter((line) => line.trim().length > 0)
|
|
100
|
-
.slice(-limit);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function statusEmoji(status: NodeStatus, theme: { fg: ThemeFg }): string {
|
|
104
|
-
switch (status) {
|
|
105
|
-
case "running":
|
|
106
|
-
return theme.fg("warning", "⏳");
|
|
107
|
-
case "error":
|
|
108
|
-
return theme.fg("error", "❌");
|
|
109
|
-
default:
|
|
110
|
-
return theme.fg("success", "✅");
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function statusFromResult(result: SingleResult): NodeStatus {
|
|
115
|
-
if (result.exitCode === -1) return "running";
|
|
116
|
-
return isResultError(result) ? "error" : "success";
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function countNodes(nodes: TreeNode[]): TreeCounts {
|
|
120
|
-
const counts: TreeCounts = {
|
|
121
|
-
total: 0,
|
|
122
|
-
running: 0,
|
|
123
|
-
success: 0,
|
|
124
|
-
error: 0,
|
|
125
|
-
finished: 0,
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const visit = (node: TreeNode) => {
|
|
129
|
-
counts.total++;
|
|
130
|
-
if (node.status === "running") counts.running++;
|
|
131
|
-
if (node.status === "success") counts.success++;
|
|
132
|
-
if (node.status === "error") counts.error++;
|
|
133
|
-
if (node.status !== "running") counts.finished++;
|
|
134
|
-
for (const child of node.children) visit(child);
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
for (const node of nodes) visit(node);
|
|
138
|
-
return counts;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function hasNestedChildren(nodes: TreeNode[]): boolean {
|
|
142
|
-
return nodes.some((node) => node.children.length > 0 || hasNestedChildren(node.children));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function extractPendingSubagentCalls(messages: SingleResult["messages"]): PendingSubagentCall[] {
|
|
146
|
-
const calls: PendingSubagentCall[] = [];
|
|
147
|
-
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
|
148
|
-
const message = messages[messageIndex] as any;
|
|
149
|
-
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
150
|
-
for (let partIndex = 0; partIndex < message.content.length; partIndex++) {
|
|
151
|
-
const part = message.content[partIndex] as any;
|
|
152
|
-
if (part?.type !== "toolCall" || part?.name !== "subagent") continue;
|
|
153
|
-
const args = part.arguments && typeof part.arguments === "object" ? part.arguments : {};
|
|
154
|
-
const tasks = Array.isArray((args as any).tasks)
|
|
155
|
-
? (args as any).tasks
|
|
156
|
-
.filter((task: any) => task && typeof task.agent === "string")
|
|
157
|
-
.map((task: any) => ({
|
|
158
|
-
agent: task.agent,
|
|
159
|
-
task: typeof task.task === "string" ? task.task : undefined,
|
|
160
|
-
}))
|
|
161
|
-
: [];
|
|
162
|
-
calls.push({
|
|
163
|
-
toolCallId:
|
|
164
|
-
typeof part.toolCallId === "string"
|
|
165
|
-
? part.toolCallId
|
|
166
|
-
: typeof part.id === "string"
|
|
167
|
-
? part.id
|
|
168
|
-
: `${messageIndex}:${partIndex}`,
|
|
169
|
-
tasks,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return calls;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function buildPendingNodes(call: PendingSubagentCall): TreeNode[] {
|
|
177
|
-
return call.tasks.map((task) => ({
|
|
178
|
-
label: task.agent,
|
|
179
|
-
status: "running",
|
|
180
|
-
task: task.task,
|
|
181
|
-
children: [],
|
|
182
|
-
}));
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function buildNodesFromNestedResult(nested: NestedSubagentResult): TreeNode[] {
|
|
186
|
-
return nested.details.results.map((result) => buildResultNode(result));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function subagentCallSignature(call: PendingSubagentCall): string {
|
|
190
|
-
return JSON.stringify(call.tasks.map((task) => ({ agent: task.agent, task: task.task ?? "" })));
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function nestedResultIsHealthy(nested: NestedSubagentResult | undefined): boolean {
|
|
194
|
-
if (!nested || nested.isError) return false;
|
|
195
|
-
return nested.details.results.every((result) => !isResultError(result));
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function buildNestedChildren(result: SingleResult): TreeNode[] {
|
|
199
|
-
const parentIsRunning = result.exitCode === -1;
|
|
200
|
-
const completedByToolCallId = new Map<string, NestedSubagentResult>();
|
|
201
|
-
for (const nested of getNestedSubagentResults(result.messages)) {
|
|
202
|
-
completedByToolCallId.set(nested.toolCallId, nested);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const calls = extractPendingSubagentCalls(result.messages);
|
|
206
|
-
const laterResumeBySignature = new Map<string, number>();
|
|
207
|
-
calls.forEach((call, index) => {
|
|
208
|
-
const completed = completedByToolCallId.get(call.toolCallId);
|
|
209
|
-
// A resumed call has the same task signature as the interrupted call but a
|
|
210
|
-
// newer toolCallId. Prefer that newer running/successful tree over the old
|
|
211
|
-
// synthetic/aborted result so resumed nested subagents render in-place.
|
|
212
|
-
if (!completed || nestedResultIsHealthy(completed)) {
|
|
213
|
-
laterResumeBySignature.set(subagentCallSignature(call), index);
|
|
214
|
-
}
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
const nodes: TreeNode[] = [];
|
|
218
|
-
calls.forEach((call, index) => {
|
|
219
|
-
const completed = completedByToolCallId.get(call.toolCallId);
|
|
220
|
-
const newerEquivalent = laterResumeBySignature.get(subagentCallSignature(call));
|
|
221
|
-
if (
|
|
222
|
-
newerEquivalent !== undefined &&
|
|
223
|
-
newerEquivalent > index &&
|
|
224
|
-
(!completed || completed.isError || !nestedResultIsHealthy(completed))
|
|
225
|
-
) {
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (completed && isSubagentDetails(completed.details)) {
|
|
230
|
-
nodes.push(...buildNodesFromNestedResult(completed));
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
// Unmatched subagent tool calls are useful while the parent is still
|
|
234
|
-
// running (they show live pending children). Once the parent finished,
|
|
235
|
-
// unmatched calls are stale history from an interrupted/resumed session and
|
|
236
|
-
// must not keep the whole tree in a perpetual "running" state.
|
|
237
|
-
if (parentIsRunning) nodes.push(...buildPendingNodes(call));
|
|
238
|
-
});
|
|
239
|
-
return nodes;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function formatToolArgPreview(toolName: string, args: Record<string, unknown>): string {
|
|
243
|
-
const shorten = (p: string) => {
|
|
244
|
-
const home = os.homedir();
|
|
245
|
-
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
246
|
-
};
|
|
247
|
-
const truncateTo = (s: string, n: number) =>
|
|
248
|
-
s.length > n ? s.slice(0, n) + "\u2026" : s;
|
|
249
|
-
|
|
250
|
-
switch (toolName) {
|
|
251
|
-
case "bash": {
|
|
252
|
-
const cmd = (args.command as string) || "";
|
|
253
|
-
return truncateTo(cmd.replace(/\s+/g, " "), 52);
|
|
254
|
-
}
|
|
255
|
-
case "read":
|
|
256
|
-
case "write":
|
|
257
|
-
case "edit":
|
|
258
|
-
return shorten(truncateTo((args.path ?? args.file_path ?? "") as string, 52));
|
|
259
|
-
case "grep":
|
|
260
|
-
return truncateTo(`/${args.pattern}/`, 30) +
|
|
261
|
-
(args.path ? ` in ${shorten(args.path as string)}` : "");
|
|
262
|
-
case "find":
|
|
263
|
-
return truncateTo((args.pattern ?? "*") as string, 30) +
|
|
264
|
-
(args.path ? ` in ${shorten(args.path as string)}` : "");
|
|
265
|
-
case "subagent": {
|
|
266
|
-
const tasks = (args.tasks as any[]) ?? [];
|
|
267
|
-
return tasks.map((t: any) => t.agent).join(", ");
|
|
268
|
-
}
|
|
269
|
-
default:
|
|
270
|
-
return "";
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function formatLiveLogEntry(
|
|
275
|
-
entry: LiveLogEntry,
|
|
276
|
-
theme: { fg: ThemeFg },
|
|
277
|
-
): string {
|
|
278
|
-
switch (entry.kind) {
|
|
279
|
-
case "turn_start":
|
|
280
|
-
return theme.fg("muted", "\u27f3") + " " + theme.fg("dim", "thinking\u2026");
|
|
281
|
-
|
|
282
|
-
case "turn_end": {
|
|
283
|
-
const tokens = entry.inputTokens || entry.outputTokens
|
|
284
|
-
? " " + theme.fg("dim",
|
|
285
|
-
`\u2191${formatTokens(entry.inputTokens)} \u2193${formatTokens(entry.outputTokens)}`)
|
|
286
|
-
: "";
|
|
287
|
-
return (
|
|
288
|
-
theme.fg("success", "\u2713") +
|
|
289
|
-
" " +
|
|
290
|
-
theme.fg("muted", `turn ${entry.turn}`) +
|
|
291
|
-
tokens
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
case "tool_start": {
|
|
296
|
-
const argPreview = formatToolArgPreview(entry.toolName, entry.args);
|
|
297
|
-
return (
|
|
298
|
-
theme.fg("muted", "\u2192") +
|
|
299
|
-
" " +
|
|
300
|
-
theme.fg("accent", entry.toolName) +
|
|
301
|
-
(argPreview ? " " + theme.fg("dim", argPreview) : "")
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
case "tool_end":
|
|
306
|
-
return (
|
|
307
|
-
theme.fg("success", "\u2713") +
|
|
308
|
-
" " +
|
|
309
|
-
theme.fg("accent", entry.toolName)
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function buildLeafPreview(result: SingleResult): string[] | undefined {
|
|
315
|
-
const items = getDisplayItems(result.messages);
|
|
316
|
-
const lines: string[] = [];
|
|
317
|
-
for (const item of items) {
|
|
318
|
-
if (item.type === "text") {
|
|
319
|
-
lines.push(...lastNonEmptyLines(item.text, OUTPUT_PREVIEW_LINE_COUNT));
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
const finalOutput = getFinalOutput(result.messages);
|
|
323
|
-
if (finalOutput) lines.push(...lastNonEmptyLines(finalOutput, OUTPUT_PREVIEW_LINE_COUNT));
|
|
324
|
-
const unique = lines.filter((line, index) => line && lines.indexOf(line) === index);
|
|
325
|
-
return unique.length > 0 ? unique.slice(-OUTPUT_PREVIEW_LINE_COUNT) : undefined;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function buildResultNode(result: SingleResult): TreeNode {
|
|
329
|
-
const status = statusFromResult(result);
|
|
330
|
-
const usage = formatUsage(result.usage, result.model);
|
|
331
|
-
const metaParts: string[] = [result.agentSource];
|
|
332
|
-
if (usage) metaParts.push(usage);
|
|
333
|
-
if (status === "error") {
|
|
334
|
-
const errorText = result.errorMessage || result.stderr || result.stopReason;
|
|
335
|
-
if (errorText) metaParts.push(truncate(errorText.replace(/\s+/g, " "), 120));
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const children = buildNestedChildren(result);
|
|
339
|
-
const isRunning = status === "running";
|
|
340
|
-
return {
|
|
341
|
-
label: result.agent,
|
|
342
|
-
status,
|
|
343
|
-
meta: metaParts.join(" • "),
|
|
344
|
-
task: result.task,
|
|
345
|
-
liveActivity: isRunning && result.liveLog?.length > 0 ? result.liveLog : undefined,
|
|
346
|
-
outputPreview: !isRunning && children.length === 0 ? buildLeafPreview(result) : undefined,
|
|
347
|
-
children,
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function buildTopLevelNodes(details: SubagentDetails): TreeNode[] {
|
|
352
|
-
return details.results.map((result) => buildResultNode(result));
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function renderTreeLines(
|
|
356
|
-
nodes: TreeNode[],
|
|
357
|
-
theme: { fg: ThemeFg },
|
|
358
|
-
showOutputPreview: boolean,
|
|
359
|
-
depth = 0,
|
|
360
|
-
prefix = "",
|
|
361
|
-
): string[] {
|
|
362
|
-
const lines: string[] = [];
|
|
363
|
-
|
|
364
|
-
nodes.forEach((node, index) => {
|
|
365
|
-
const indent = " ".repeat(depth);
|
|
366
|
-
const number = prefix ? `${prefix}.${index + 1}` : `${index + 1}`;
|
|
367
|
-
const numberPrefix = broadcastNumberingActive ? `${number}. ` : "";
|
|
368
|
-
let line = `${indent}${numberPrefix}${statusEmoji(node.status, theme)} ${theme.fg("accent", node.label)}`;
|
|
369
|
-
if (node.meta) line += ` ${theme.fg("dim", node.meta)}`;
|
|
370
|
-
lines.push(line);
|
|
371
|
-
|
|
372
|
-
if (showOutputPreview && node.outputPreview && node.outputPreview.length > 0) {
|
|
373
|
-
for (const outputLine of node.outputPreview) {
|
|
374
|
-
lines.push(`${indent} ${theme.fg("toolOutput", outputLine)}`);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (showOutputPreview && node.liveActivity && node.liveActivity.length > 0) {
|
|
379
|
-
for (const entry of node.liveActivity) {
|
|
380
|
-
lines.push(`${indent} ${formatLiveLogEntry(entry, theme)}`);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
if (node.children.length > 0) {
|
|
385
|
-
lines.push(...renderTreeLines(node.children, theme, false, depth + 1, number));
|
|
386
|
-
}
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
return lines;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function topLevelSummary(details: SubagentDetails, counts: TreeCounts): string {
|
|
393
|
-
// aggregatedUsage includes own agents + all their nested descendants;
|
|
394
|
-
// fall back to summing only direct results for old serialised data lacking the field.
|
|
395
|
-
const totalUsage = formatUsage(
|
|
396
|
-
details.aggregatedUsage ?? aggregateUsage(details.results),
|
|
397
|
-
);
|
|
398
|
-
const parts = [
|
|
399
|
-
`${counts.running} running`,
|
|
400
|
-
`${counts.finished}/${counts.total} finished`,
|
|
401
|
-
`${counts.success} ok`,
|
|
402
|
-
`${counts.error} error`,
|
|
403
|
-
];
|
|
404
|
-
if (totalUsage) parts.push(totalUsage);
|
|
405
|
-
return parts.join(" • ");
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// ---------------------------------------------------------------------------
|
|
409
|
-
// renderCall — shown while the tool is being invoked
|
|
410
|
-
// ---------------------------------------------------------------------------
|
|
411
|
-
|
|
412
|
-
export function renderCall(
|
|
413
|
-
args: Record<string, any>,
|
|
414
|
-
theme: { fg: ThemeFg; bold: (s: string) => string },
|
|
415
|
-
context?: { isPartial?: boolean; isError?: boolean },
|
|
416
|
-
): Text {
|
|
417
|
-
const tasks = Array.isArray(args.tasks) ? args.tasks : [];
|
|
418
|
-
const count = tasks.length;
|
|
419
|
-
const icon = context?.isPartial === false
|
|
420
|
-
? context.isError
|
|
421
|
-
? theme.fg("error", "❌")
|
|
422
|
-
: theme.fg("success", "✅")
|
|
423
|
-
: theme.fg("warning", "⏳");
|
|
424
|
-
let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `${count} task${count === 1 ? "" : "s"}`)}`;
|
|
425
|
-
for (const task of tasks.slice(0, 6)) {
|
|
426
|
-
const agent = typeof task?.agent === "string" ? task.agent : "...";
|
|
427
|
-
const preview = typeof task?.task === "string" ? ` ${truncate(task.task, 56)}` : "";
|
|
428
|
-
text += `\n ${icon} ${theme.fg("accent", agent)}${theme.fg("dim", preview)}`;
|
|
429
|
-
}
|
|
430
|
-
if (tasks.length > 6) text += `\n ${theme.fg("muted", `... +${tasks.length - 6} more`)}`;
|
|
431
|
-
return new Text(text, 0, 0);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// ---------------------------------------------------------------------------
|
|
435
|
-
// renderResult — shown after the tool completes / streams updates
|
|
436
|
-
// ---------------------------------------------------------------------------
|
|
437
|
-
|
|
438
|
-
export function renderResult(
|
|
439
|
-
result: { content: Array<{ type: string; text?: string }>; details?: unknown },
|
|
440
|
-
_expanded: boolean,
|
|
441
|
-
theme: { fg: ThemeFg; bold: (s: string) => string },
|
|
442
|
-
): Container | Text {
|
|
443
|
-
const details = result.details as SubagentDetails | undefined;
|
|
444
|
-
if (!details || details.results.length === 0) {
|
|
445
|
-
const first = result.content[0];
|
|
446
|
-
return new Text(first?.type === "text" && first.text ? first.text : "(no output)", 0, 0);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const nodes = buildTopLevelNodes(details);
|
|
450
|
-
const counts = countNodes(nodes);
|
|
451
|
-
const showOutputPreview = !hasNestedChildren(nodes);
|
|
452
|
-
const icon = counts.running > 0
|
|
453
|
-
? theme.fg("warning", "⏳")
|
|
454
|
-
: counts.error > 0
|
|
455
|
-
? theme.fg("error", "❌")
|
|
456
|
-
: theme.fg("success", "✅");
|
|
457
|
-
|
|
458
|
-
const container = new Container();
|
|
459
|
-
container.addChild(
|
|
460
|
-
new Text(
|
|
461
|
-
`${icon} ${theme.fg("toolTitle", theme.bold("subagent tree "))}${theme.fg("dim", topLevelSummary(details, counts))}`,
|
|
462
|
-
0,
|
|
463
|
-
0,
|
|
464
|
-
),
|
|
465
|
-
);
|
|
466
|
-
|
|
467
|
-
container.addChild(new Spacer(1));
|
|
468
|
-
container.addChild(new Text(renderTreeLines(nodes, theme, showOutputPreview).join("\n"), 0, 0));
|
|
469
|
-
|
|
470
|
-
return container;
|
|
471
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* TUI rendering for subagent tool calls and results.
|
|
3
|
+
*
|
|
4
|
+
* The pure tree-building and line-rendering logic lives in `tree.ts` (no
|
|
5
|
+
* pi-tui dependency, unit-tested). This module only wraps those lines in
|
|
6
|
+
* pi-tui Containers/Text widgets.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
|
10
|
+
import type { SubagentDetails } from "./types.js";
|
|
11
|
+
import {
|
|
12
|
+
type ThemeFg,
|
|
13
|
+
buildTopLevelNodes,
|
|
14
|
+
countNodes,
|
|
15
|
+
hasNestedChildren,
|
|
16
|
+
renderTreeLines,
|
|
17
|
+
setBroadcastNumberingActive,
|
|
18
|
+
topLevelSummary,
|
|
19
|
+
truncate,
|
|
20
|
+
} from "./tree.js";
|
|
21
|
+
|
|
22
|
+
export { setBroadcastNumberingActive };
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// renderCall — shown while the tool is being invoked
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export function renderCall(
|
|
29
|
+
args: Record<string, any>,
|
|
30
|
+
theme: { fg: ThemeFg; bold: (s: string) => string },
|
|
31
|
+
context?: { isPartial?: boolean; isError?: boolean },
|
|
32
|
+
): Text {
|
|
33
|
+
const tasks = Array.isArray(args.tasks) ? args.tasks : [];
|
|
34
|
+
const count = tasks.length;
|
|
35
|
+
const icon = context?.isPartial === false
|
|
36
|
+
? context.isError
|
|
37
|
+
? theme.fg("error", "❌")
|
|
38
|
+
: theme.fg("success", "✅")
|
|
39
|
+
: theme.fg("warning", "⏳");
|
|
40
|
+
let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `${count} task${count === 1 ? "" : "s"}`)}`;
|
|
41
|
+
for (const task of tasks.slice(0, 6)) {
|
|
42
|
+
const agent = typeof task?.agent === "string" ? task.agent : "...";
|
|
43
|
+
const preview = typeof task?.task === "string" ? ` ${truncate(task.task, 56)}` : "";
|
|
44
|
+
text += `\n ${icon} ${theme.fg("accent", agent)}${theme.fg("dim", preview)}`;
|
|
45
|
+
}
|
|
46
|
+
if (tasks.length > 6) text += `\n ${theme.fg("muted", `... +${tasks.length - 6} more`)}`;
|
|
47
|
+
return new Text(text, 0, 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// renderResult — shown after the tool completes / streams updates
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export function renderResult(
|
|
55
|
+
result: { content: Array<{ type: string; text?: string }>; details?: unknown },
|
|
56
|
+
_expanded: boolean,
|
|
57
|
+
theme: { fg: ThemeFg; bold: (s: string) => string },
|
|
58
|
+
): Container | Text {
|
|
59
|
+
const details = result.details as SubagentDetails | undefined;
|
|
60
|
+
if (!details || details.results.length === 0) {
|
|
61
|
+
const first = result.content[0];
|
|
62
|
+
return new Text(first?.type === "text" && first.text ? first.text : "(no output)", 0, 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const nodes = buildTopLevelNodes(details);
|
|
66
|
+
const counts = countNodes(nodes);
|
|
67
|
+
const showOutputPreview = !hasNestedChildren(nodes);
|
|
68
|
+
const icon = counts.running > 0
|
|
69
|
+
? theme.fg("warning", "⏳")
|
|
70
|
+
: counts.error > 0
|
|
71
|
+
? theme.fg("error", "❌")
|
|
72
|
+
: theme.fg("success", "✅");
|
|
73
|
+
|
|
74
|
+
const container = new Container();
|
|
75
|
+
container.addChild(
|
|
76
|
+
new Text(
|
|
77
|
+
`${icon} ${theme.fg("toolTitle", theme.bold("subagent tree "))}${theme.fg("dim", topLevelSummary(details, counts))}`,
|
|
78
|
+
0,
|
|
79
|
+
0,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
container.addChild(new Spacer(1));
|
|
84
|
+
container.addChild(new Text(renderTreeLines(nodes, theme, showOutputPreview).join("\n"), 0, 0));
|
|
85
|
+
|
|
86
|
+
return container;
|
|
87
|
+
}
|
package/runner.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
extractToolCalls,
|
|
21
21
|
getFinalOutput,
|
|
22
22
|
getNestedSubagentErrorSummary,
|
|
23
|
+
isSubagentDetails,
|
|
23
24
|
} from "./types.js";
|
|
24
25
|
import { SUBAGENT_SESSION_ROOT_ENV } from "./resume.js";
|
|
25
26
|
import {
|
|
@@ -414,9 +415,22 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
|
|
|
414
415
|
if (event.type === "tool_result_end" && event.message) {
|
|
415
416
|
const msg = event.message as Message;
|
|
416
417
|
if (!hasMessage(result, msg)) result.messages.push(msg);
|
|
418
|
+
if ((msg as any).toolName === "subagent" && typeof (msg as any).toolCallId === "string") {
|
|
419
|
+
delete result.liveNestedSubagents?.[(msg as any).toolCallId];
|
|
420
|
+
}
|
|
417
421
|
return true;
|
|
418
422
|
}
|
|
419
423
|
|
|
424
|
+
if (event.type === "subagent_progress") {
|
|
425
|
+
const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
|
|
426
|
+
if (toolCallId && isSubagentDetails(event.details)) {
|
|
427
|
+
result.liveNestedSubagents ??= {};
|
|
428
|
+
result.liveNestedSubagents[toolCallId] = event.details;
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
|
|
420
434
|
if (event.type === "turn_start") {
|
|
421
435
|
result.turnInProgress = true;
|
|
422
436
|
pushLiveLog(result, { kind: "turn_start" });
|
|
@@ -620,6 +634,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
620
634
|
turnInProgress: false,
|
|
621
635
|
liveToolExecutions: initialResult?.liveToolExecutions,
|
|
622
636
|
liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
|
|
637
|
+
liveNestedSubagents: initialResult?.liveNestedSubagents ? { ...initialResult.liveNestedSubagents } : undefined,
|
|
623
638
|
sessionDir,
|
|
624
639
|
};
|
|
625
640
|
|
package/tree.ts
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure tree-building and line-rendering logic for the subagent TUI.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately has NO dependency on `@mariozechner/pi-tui` so it
|
|
5
|
+
* can be unit-tested without the (peer) TUI package installed. `render.ts`
|
|
6
|
+
* imports from here and wraps the produced lines in pi-tui Containers/Text.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import {
|
|
11
|
+
type LiveLogEntry,
|
|
12
|
+
type NestedSubagentResult,
|
|
13
|
+
type SingleResult,
|
|
14
|
+
type SubagentDetails,
|
|
15
|
+
type UsageStats,
|
|
16
|
+
aggregateUsage,
|
|
17
|
+
getDisplayItems,
|
|
18
|
+
getFinalOutput,
|
|
19
|
+
getNestedSubagentResults,
|
|
20
|
+
isResultError,
|
|
21
|
+
isSubagentDetails,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
export const OUTPUT_PREVIEW_LINE_COUNT = 6;
|
|
25
|
+
|
|
26
|
+
let broadcastNumberingActive = false;
|
|
27
|
+
|
|
28
|
+
export function setBroadcastNumberingActive(active: boolean): void {
|
|
29
|
+
broadcastNumberingActive = active;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ThemeFg = (color: string, text: string) => string;
|
|
33
|
+
export type NodeStatus = "running" | "success" | "error";
|
|
34
|
+
|
|
35
|
+
export interface TreeNode {
|
|
36
|
+
label: string;
|
|
37
|
+
status: NodeStatus;
|
|
38
|
+
meta?: string;
|
|
39
|
+
task?: string;
|
|
40
|
+
outputPreview?: string[];
|
|
41
|
+
liveActivity?: LiveLogEntry[];
|
|
42
|
+
children: TreeNode[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TreeCounts {
|
|
46
|
+
total: number;
|
|
47
|
+
running: number;
|
|
48
|
+
success: number;
|
|
49
|
+
error: number;
|
|
50
|
+
finished: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface PendingSubagentCall {
|
|
54
|
+
toolCallId: string;
|
|
55
|
+
tasks: Array<{ agent: string; task?: string }>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Formatting helpers
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export function formatTokens(count: number): string {
|
|
63
|
+
if (count < 1000) return count.toString();
|
|
64
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
65
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
66
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatUsage(usage: Partial<UsageStats>, model?: string): string {
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
const totalTokens =
|
|
72
|
+
(usage.input || 0) +
|
|
73
|
+
(usage.output || 0) +
|
|
74
|
+
(usage.cacheRead || 0) +
|
|
75
|
+
(usage.cacheWrite || 0);
|
|
76
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
77
|
+
if (totalTokens > 0) parts.push(`tok:${formatTokens(totalTokens)}`);
|
|
78
|
+
if (usage.input) parts.push(`in:${formatTokens(usage.input)}`);
|
|
79
|
+
if (usage.output) parts.push(`out:${formatTokens(usage.output)}`);
|
|
80
|
+
if (usage.cacheRead) parts.push(`cacheR:${formatTokens(usage.cacheRead)}`);
|
|
81
|
+
if (usage.cacheWrite) parts.push(`cacheW:${formatTokens(usage.cacheWrite)}`);
|
|
82
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
83
|
+
if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
84
|
+
if (model) parts.push(model);
|
|
85
|
+
return parts.join(" • ");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function truncate(text: string, maxLen: number): string {
|
|
89
|
+
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function splitOutputLines(text: string): string[] {
|
|
93
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
94
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function lastNonEmptyLines(text: string, limit: number): string[] {
|
|
99
|
+
return splitOutputLines(text)
|
|
100
|
+
.map((line) => line.trimEnd())
|
|
101
|
+
.filter((line) => line.trim().length > 0)
|
|
102
|
+
.slice(-limit);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function statusEmoji(status: NodeStatus, theme: { fg: ThemeFg }): string {
|
|
106
|
+
switch (status) {
|
|
107
|
+
case "running":
|
|
108
|
+
return theme.fg("warning", "⏳");
|
|
109
|
+
case "error":
|
|
110
|
+
return theme.fg("error", "❌");
|
|
111
|
+
default:
|
|
112
|
+
return theme.fg("success", "✅");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function statusFromResult(result: SingleResult): NodeStatus {
|
|
117
|
+
if (result.exitCode === -1) return "running";
|
|
118
|
+
return isResultError(result) ? "error" : "success";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function countNodes(nodes: TreeNode[]): TreeCounts {
|
|
122
|
+
const counts: TreeCounts = {
|
|
123
|
+
total: 0,
|
|
124
|
+
running: 0,
|
|
125
|
+
success: 0,
|
|
126
|
+
error: 0,
|
|
127
|
+
finished: 0,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const visit = (node: TreeNode) => {
|
|
131
|
+
counts.total++;
|
|
132
|
+
if (node.status === "running") counts.running++;
|
|
133
|
+
if (node.status === "success") counts.success++;
|
|
134
|
+
if (node.status === "error") counts.error++;
|
|
135
|
+
if (node.status !== "running") counts.finished++;
|
|
136
|
+
for (const child of node.children) visit(child);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
for (const node of nodes) visit(node);
|
|
140
|
+
return counts;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function hasNestedChildren(nodes: TreeNode[]): boolean {
|
|
144
|
+
return nodes.some((node) => node.children.length > 0 || hasNestedChildren(node.children));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractPendingSubagentCalls(messages: SingleResult["messages"]): PendingSubagentCall[] {
|
|
148
|
+
const calls: PendingSubagentCall[] = [];
|
|
149
|
+
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
|
150
|
+
const message = messages[messageIndex] as any;
|
|
151
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
152
|
+
for (let partIndex = 0; partIndex < message.content.length; partIndex++) {
|
|
153
|
+
const part = message.content[partIndex] as any;
|
|
154
|
+
if (part?.type !== "toolCall" || part?.name !== "subagent") continue;
|
|
155
|
+
const args = part.arguments && typeof part.arguments === "object" ? part.arguments : {};
|
|
156
|
+
const tasks = Array.isArray((args as any).tasks)
|
|
157
|
+
? (args as any).tasks
|
|
158
|
+
.filter((task: any) => task && typeof task.agent === "string")
|
|
159
|
+
.map((task: any) => ({
|
|
160
|
+
agent: task.agent,
|
|
161
|
+
task: typeof task.task === "string" ? task.task : undefined,
|
|
162
|
+
}))
|
|
163
|
+
: [];
|
|
164
|
+
calls.push({
|
|
165
|
+
toolCallId:
|
|
166
|
+
typeof part.toolCallId === "string"
|
|
167
|
+
? part.toolCallId
|
|
168
|
+
: typeof part.id === "string"
|
|
169
|
+
? part.id
|
|
170
|
+
: `${messageIndex}:${partIndex}`,
|
|
171
|
+
tasks,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return calls;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildPendingNodes(call: PendingSubagentCall): TreeNode[] {
|
|
179
|
+
return call.tasks.map((task) => ({
|
|
180
|
+
label: task.agent,
|
|
181
|
+
status: "running",
|
|
182
|
+
task: task.task,
|
|
183
|
+
children: [],
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildNodesFromDetails(details: SubagentDetails): TreeNode[] {
|
|
188
|
+
return details.results.map((result) => buildResultNode(result));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildNodesFromNestedResult(nested: NestedSubagentResult): TreeNode[] {
|
|
192
|
+
return buildNodesFromDetails(nested.details);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function subagentCallSignature(call: PendingSubagentCall): string {
|
|
196
|
+
return JSON.stringify(call.tasks.map((task) => ({ agent: task.agent, task: task.task ?? "" })));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function nestedResultIsHealthy(nested: NestedSubagentResult | undefined): boolean {
|
|
200
|
+
if (!nested || nested.isError) return false;
|
|
201
|
+
return nested.details.results.every((result) => !isResultError(result));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function buildLiveDetailsSignature(details: SubagentDetails): string {
|
|
205
|
+
return JSON.stringify(details.results.map((result) => ({ agent: result.agent, task: result.task ?? "" })));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function findLiveNestedDetailsForCall(
|
|
209
|
+
result: SingleResult,
|
|
210
|
+
call: PendingSubagentCall,
|
|
211
|
+
usedLiveKeys: Set<string>,
|
|
212
|
+
): SubagentDetails | undefined {
|
|
213
|
+
const live = result.liveNestedSubagents;
|
|
214
|
+
if (!live) return undefined;
|
|
215
|
+
|
|
216
|
+
const byId = live[call.toolCallId];
|
|
217
|
+
if (isSubagentDetails(byId)) {
|
|
218
|
+
usedLiveKeys.add(call.toolCallId);
|
|
219
|
+
return byId;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Some pi versions pass a different internal id to Tool.execute than the id
|
|
223
|
+
// stored on the assistant toolCall part. Final toolResult messages still line
|
|
224
|
+
// up by id, but live `subagent_progress` events can be keyed differently. In
|
|
225
|
+
// that case match the running nested tree by the requested agent/task
|
|
226
|
+
// signature so grandchildren render live instead of falling back to static
|
|
227
|
+
// pending placeholders.
|
|
228
|
+
const signature = subagentCallSignature(call);
|
|
229
|
+
for (const [key, details] of Object.entries(live)) {
|
|
230
|
+
if (usedLiveKeys.has(key) || !isSubagentDetails(details)) continue;
|
|
231
|
+
if (buildLiveDetailsSignature(details) !== signature) continue;
|
|
232
|
+
usedLiveKeys.add(key);
|
|
233
|
+
return details;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Fallback for cases where task text differs slightly by the time the child
|
|
237
|
+
// details are emitted. Still require the same agent sequence; count-only
|
|
238
|
+
// matching can attach progress to the wrong repeated/concurrent call.
|
|
239
|
+
const agentSignature = JSON.stringify(call.tasks.map((task) => task.agent));
|
|
240
|
+
for (const [key, details] of Object.entries(live)) {
|
|
241
|
+
if (usedLiveKeys.has(key) || !isSubagentDetails(details)) continue;
|
|
242
|
+
const liveAgentSignature = JSON.stringify(details.results.map((nestedResult) => nestedResult.agent));
|
|
243
|
+
if (liveAgentSignature !== agentSignature) continue;
|
|
244
|
+
usedLiveKeys.add(key);
|
|
245
|
+
return details;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function buildNestedChildren(result: SingleResult): TreeNode[] {
|
|
252
|
+
const parentIsRunning = result.exitCode === -1;
|
|
253
|
+
const completedByToolCallId = new Map<string, NestedSubagentResult>();
|
|
254
|
+
for (const nested of getNestedSubagentResults(result.messages)) {
|
|
255
|
+
completedByToolCallId.set(nested.toolCallId, nested);
|
|
256
|
+
}
|
|
257
|
+
const usedLiveKeys = new Set<string>();
|
|
258
|
+
|
|
259
|
+
const calls = extractPendingSubagentCalls(result.messages);
|
|
260
|
+
const laterResumeBySignature = new Map<string, number>();
|
|
261
|
+
calls.forEach((call, index) => {
|
|
262
|
+
const completed = completedByToolCallId.get(call.toolCallId);
|
|
263
|
+
// A resumed call has the same task signature as the interrupted call but a
|
|
264
|
+
// newer toolCallId. Prefer that newer running/successful tree over the old
|
|
265
|
+
// synthetic/aborted result so resumed nested subagents render in-place.
|
|
266
|
+
if (!completed || nestedResultIsHealthy(completed)) {
|
|
267
|
+
laterResumeBySignature.set(subagentCallSignature(call), index);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const nodes: TreeNode[] = [];
|
|
272
|
+
calls.forEach((call, index) => {
|
|
273
|
+
const completed = completedByToolCallId.get(call.toolCallId);
|
|
274
|
+
const newerEquivalent = laterResumeBySignature.get(subagentCallSignature(call));
|
|
275
|
+
if (
|
|
276
|
+
newerEquivalent !== undefined &&
|
|
277
|
+
newerEquivalent > index &&
|
|
278
|
+
(!completed || completed.isError || !nestedResultIsHealthy(completed))
|
|
279
|
+
) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (completed && isSubagentDetails(completed.details)) {
|
|
284
|
+
nodes.push(...buildNodesFromNestedResult(completed));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const liveDetails = parentIsRunning
|
|
289
|
+
? findLiveNestedDetailsForCall(result, call, usedLiveKeys)
|
|
290
|
+
: undefined;
|
|
291
|
+
if (liveDetails) {
|
|
292
|
+
nodes.push(...buildNodesFromDetails(liveDetails));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Unmatched subagent tool calls are useful while the parent is still
|
|
297
|
+
// running (they show live pending children). Once the parent finished,
|
|
298
|
+
// unmatched calls are stale history from an interrupted/resumed session and
|
|
299
|
+
// must not keep the whole tree in a perpetual "running" state.
|
|
300
|
+
if (parentIsRunning) nodes.push(...buildPendingNodes(call));
|
|
301
|
+
});
|
|
302
|
+
return nodes;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatToolArgPreview(toolName: string, args: Record<string, unknown>): string {
|
|
306
|
+
const shorten = (p: string) => {
|
|
307
|
+
const home = os.homedir();
|
|
308
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
309
|
+
};
|
|
310
|
+
const truncateTo = (s: string, n: number) =>
|
|
311
|
+
s.length > n ? s.slice(0, n) + "\u2026" : s;
|
|
312
|
+
|
|
313
|
+
switch (toolName) {
|
|
314
|
+
case "bash": {
|
|
315
|
+
const cmd = (args.command as string) || "";
|
|
316
|
+
return truncateTo(cmd.replace(/\s+/g, " "), 52);
|
|
317
|
+
}
|
|
318
|
+
case "read":
|
|
319
|
+
case "write":
|
|
320
|
+
case "edit":
|
|
321
|
+
return shorten(truncateTo((args.path ?? args.file_path ?? "") as string, 52));
|
|
322
|
+
case "grep":
|
|
323
|
+
return truncateTo(`/${args.pattern}/`, 30) +
|
|
324
|
+
(args.path ? ` in ${shorten(args.path as string)}` : "");
|
|
325
|
+
case "find":
|
|
326
|
+
return truncateTo((args.pattern ?? "*") as string, 30) +
|
|
327
|
+
(args.path ? ` in ${shorten(args.path as string)}` : "");
|
|
328
|
+
case "subagent": {
|
|
329
|
+
const tasks = (args.tasks as any[]) ?? [];
|
|
330
|
+
return tasks.map((t: any) => t.agent).join(", ");
|
|
331
|
+
}
|
|
332
|
+
default:
|
|
333
|
+
return "";
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function formatLiveLogEntry(
|
|
338
|
+
entry: LiveLogEntry,
|
|
339
|
+
theme: { fg: ThemeFg },
|
|
340
|
+
): string {
|
|
341
|
+
switch (entry.kind) {
|
|
342
|
+
case "turn_start":
|
|
343
|
+
return theme.fg("muted", "\u27f3") + " " + theme.fg("dim", "thinking\u2026");
|
|
344
|
+
|
|
345
|
+
case "turn_end": {
|
|
346
|
+
const tokens = entry.inputTokens || entry.outputTokens
|
|
347
|
+
? " " + theme.fg("dim",
|
|
348
|
+
`\u2191${formatTokens(entry.inputTokens)} \u2193${formatTokens(entry.outputTokens)}`)
|
|
349
|
+
: "";
|
|
350
|
+
return (
|
|
351
|
+
theme.fg("success", "\u2713") +
|
|
352
|
+
" " +
|
|
353
|
+
theme.fg("muted", `turn ${entry.turn}`) +
|
|
354
|
+
tokens
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
case "tool_start": {
|
|
359
|
+
const argPreview = formatToolArgPreview(entry.toolName, entry.args);
|
|
360
|
+
return (
|
|
361
|
+
theme.fg("muted", "\u2192") +
|
|
362
|
+
" " +
|
|
363
|
+
theme.fg("accent", entry.toolName) +
|
|
364
|
+
(argPreview ? " " + theme.fg("dim", argPreview) : "")
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case "tool_end":
|
|
369
|
+
return (
|
|
370
|
+
theme.fg("success", "\u2713") +
|
|
371
|
+
" " +
|
|
372
|
+
theme.fg("accent", entry.toolName)
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function buildLeafPreview(result: SingleResult): string[] | undefined {
|
|
378
|
+
const items = getDisplayItems(result.messages);
|
|
379
|
+
const lines: string[] = [];
|
|
380
|
+
for (const item of items) {
|
|
381
|
+
if (item.type === "text") {
|
|
382
|
+
lines.push(...lastNonEmptyLines(item.text, OUTPUT_PREVIEW_LINE_COUNT));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const finalOutput = getFinalOutput(result.messages);
|
|
386
|
+
if (finalOutput) lines.push(...lastNonEmptyLines(finalOutput, OUTPUT_PREVIEW_LINE_COUNT));
|
|
387
|
+
const unique = lines.filter((line, index) => line && lines.indexOf(line) === index);
|
|
388
|
+
return unique.length > 0 ? unique.slice(-OUTPUT_PREVIEW_LINE_COUNT) : undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildResultNode(result: SingleResult): TreeNode {
|
|
392
|
+
const status = statusFromResult(result);
|
|
393
|
+
const usage = formatUsage(result.usage, result.model);
|
|
394
|
+
const metaParts: string[] = [result.agentSource];
|
|
395
|
+
if (usage) metaParts.push(usage);
|
|
396
|
+
if (status === "error") {
|
|
397
|
+
const errorText = result.errorMessage || result.stderr || result.stopReason;
|
|
398
|
+
if (errorText) metaParts.push(truncate(errorText.replace(/\s+/g, " "), 120));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const children = buildNestedChildren(result);
|
|
402
|
+
const isRunning = status === "running";
|
|
403
|
+
return {
|
|
404
|
+
label: result.agent,
|
|
405
|
+
status,
|
|
406
|
+
meta: metaParts.join(" • "),
|
|
407
|
+
task: result.task,
|
|
408
|
+
liveActivity: isRunning && result.liveLog?.length > 0 ? result.liveLog : undefined,
|
|
409
|
+
outputPreview: !isRunning && children.length === 0 ? buildLeafPreview(result) : undefined,
|
|
410
|
+
children,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function buildTopLevelNodes(details: SubagentDetails): TreeNode[] {
|
|
415
|
+
return details.results.map((result) => buildResultNode(result));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function renderTreeLines(
|
|
419
|
+
nodes: TreeNode[],
|
|
420
|
+
theme: { fg: ThemeFg },
|
|
421
|
+
showOutputPreview: boolean,
|
|
422
|
+
depth = 0,
|
|
423
|
+
prefix = "",
|
|
424
|
+
): string[] {
|
|
425
|
+
const lines: string[] = [];
|
|
426
|
+
|
|
427
|
+
nodes.forEach((node, index) => {
|
|
428
|
+
const indent = " ".repeat(depth);
|
|
429
|
+
const number = prefix ? `${prefix}.${index + 1}` : `${index + 1}`;
|
|
430
|
+
const numberPrefix = broadcastNumberingActive ? `${number}. ` : "";
|
|
431
|
+
let line = `${indent}${numberPrefix}${statusEmoji(node.status, theme)} ${theme.fg("accent", node.label)}`;
|
|
432
|
+
if (node.meta) line += ` ${theme.fg("dim", node.meta)}`;
|
|
433
|
+
lines.push(line);
|
|
434
|
+
|
|
435
|
+
if (showOutputPreview && node.outputPreview && node.outputPreview.length > 0) {
|
|
436
|
+
for (const outputLine of node.outputPreview) {
|
|
437
|
+
lines.push(`${indent} ${theme.fg("toolOutput", outputLine)}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Live activity (thinking / tool calls of a *running* agent) is always
|
|
442
|
+
// shown, at any depth and regardless of `showOutputPreview`. Previously it
|
|
443
|
+
// was gated behind `showOutputPreview`, which is disabled for the whole
|
|
444
|
+
// tree as soon as any nesting exists — so teamlead/nested runs showed only
|
|
445
|
+
// static status lines and looked frozen. liveActivity is only attached to
|
|
446
|
+
// running nodes (see buildResultNode), so completed nodes stay quiet.
|
|
447
|
+
if (node.liveActivity && node.liveActivity.length > 0) {
|
|
448
|
+
for (const entry of node.liveActivity) {
|
|
449
|
+
lines.push(`${indent} ${formatLiveLogEntry(entry, theme)}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (node.children.length > 0) {
|
|
454
|
+
lines.push(...renderTreeLines(node.children, theme, showOutputPreview, depth + 1, number));
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
return lines;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function topLevelSummary(details: SubagentDetails, counts: TreeCounts): string {
|
|
462
|
+
// aggregatedUsage includes own agents + all their nested descendants;
|
|
463
|
+
// fall back to summing only direct results for old serialised data lacking the field.
|
|
464
|
+
const totalUsage = formatUsage(
|
|
465
|
+
details.aggregatedUsage ?? aggregateUsage(details.results),
|
|
466
|
+
);
|
|
467
|
+
const parts = [
|
|
468
|
+
`${counts.running} running`,
|
|
469
|
+
`${counts.finished}/${counts.total} finished`,
|
|
470
|
+
`${counts.success} ok`,
|
|
471
|
+
`${counts.error} error`,
|
|
472
|
+
];
|
|
473
|
+
if (totalUsage) parts.push(totalUsage);
|
|
474
|
+
return parts.join(" • ");
|
|
475
|
+
}
|
package/types.ts
CHANGED
|
@@ -62,6 +62,13 @@ export interface SingleResult {
|
|
|
62
62
|
* Populated while the agent is running; each entry is one display line.
|
|
63
63
|
*/
|
|
64
64
|
liveLog: LiveLogEntry[];
|
|
65
|
+
/**
|
|
66
|
+
* Transient, streaming progress for nested subagent calls made by this agent,
|
|
67
|
+
* keyed by the nested subagent toolCallId. This is intentionally not part of
|
|
68
|
+
* the durable conversation history; final nested results still live in
|
|
69
|
+
* messages as toolResult entries.
|
|
70
|
+
*/
|
|
71
|
+
liveNestedSubagents?: Record<string, SubagentDetails>;
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
/** A node in the per-subagent usage tree (own stats + recursive children) */
|