@pi-archimedes/subagent 0.3.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/package.json +31 -0
- package/src/compact.ts +360 -0
- package/src/cost.ts +8 -0
- package/src/execute.ts +111 -0
- package/src/expanded.ts +221 -0
- package/src/format.ts +60 -0
- package/src/handlers.ts +158 -0
- package/src/index.ts +224 -0
- package/src/render.ts +123 -0
- package/src/spawn.ts +73 -0
- package/src/stream.ts +177 -0
- package/src/types.ts +76 -0
package/src/expanded.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SubagentDetails, SubagentProgress, SubagentResult } from "./types.js";
|
|
3
|
+
import { formatTokens, formatDuration, truncLine, buildStatsLine } from "./format.js";
|
|
4
|
+
|
|
5
|
+
type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
|
|
6
|
+
|
|
7
|
+
// ── Expanded completed result ───────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export function buildExpandedText(
|
|
10
|
+
result: SubagentResult,
|
|
11
|
+
progress: SubagentProgress | undefined,
|
|
12
|
+
theme: Theme,
|
|
13
|
+
): string {
|
|
14
|
+
const lines: string[] = [];
|
|
15
|
+
|
|
16
|
+
// Stats line (same as compact view)
|
|
17
|
+
const modelName = progress?.model ?? result.model;
|
|
18
|
+
const modelLabel = modelName ? theme.fg("accent", modelName) : "";
|
|
19
|
+
const statsLine = buildStatsLine({
|
|
20
|
+
turns: result.usage.turns,
|
|
21
|
+
toolCount: result.progressSummary?.toolCount,
|
|
22
|
+
tokens: result.progressSummary?.tokens,
|
|
23
|
+
durationMs: result.progressSummary?.durationMs,
|
|
24
|
+
cost: result.usage.cost,
|
|
25
|
+
}, theme);
|
|
26
|
+
const expandHint = theme.fg("muted", "(ctrl+o)");
|
|
27
|
+
const statsParts = [modelLabel, statsLine, expandHint].filter(Boolean);
|
|
28
|
+
if (statsParts.length > 0) {
|
|
29
|
+
lines.push(statsParts.join(" "));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Task
|
|
33
|
+
if (result.task) {
|
|
34
|
+
lines.push("");
|
|
35
|
+
lines.push(theme.fg("dim", "Task: " + result.task));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Tool calls history
|
|
39
|
+
const toolCalls = progress?.toolCalls;
|
|
40
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
41
|
+
lines.push("");
|
|
42
|
+
for (const call of toolCalls) {
|
|
43
|
+
lines.push(theme.fg("dim", "- " + call));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Output - show live streaming output or final output
|
|
48
|
+
const outputText = progress?.output ?? result.finalOutput;
|
|
49
|
+
if (outputText) {
|
|
50
|
+
lines.push("");
|
|
51
|
+
lines.push(outputText);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Error
|
|
55
|
+
if (result.error) {
|
|
56
|
+
lines.push("");
|
|
57
|
+
lines.push(theme.fg("error", "✗ " + result.error));
|
|
58
|
+
} else if (result.exitCode === 0) {
|
|
59
|
+
lines.push("");
|
|
60
|
+
lines.push(theme.fg("success", "✓ Done"));
|
|
61
|
+
} else {
|
|
62
|
+
lines.push("");
|
|
63
|
+
lines.push(theme.fg("error", "✗ Failed"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderExpanded(
|
|
70
|
+
text: Text,
|
|
71
|
+
result: SubagentResult,
|
|
72
|
+
progress: SubagentProgress | undefined,
|
|
73
|
+
theme: Theme,
|
|
74
|
+
): Text {
|
|
75
|
+
text.setText(buildExpandedText(result, progress, theme));
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Expanded streaming progress ─────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export function renderProgressExpanded(
|
|
82
|
+
text: Text,
|
|
83
|
+
progress: SubagentProgress,
|
|
84
|
+
theme: Theme,
|
|
85
|
+
): Text {
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
|
|
88
|
+
// Stats line (same as compact view)
|
|
89
|
+
const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
|
|
90
|
+
const statsLine = buildStatsLine({
|
|
91
|
+
toolCount: progress.toolCount,
|
|
92
|
+
tokens: progress.tokens,
|
|
93
|
+
durationMs: progress.durationMs,
|
|
94
|
+
cost: progress.cost,
|
|
95
|
+
}, theme);
|
|
96
|
+
const expandHint = theme.fg("muted", "(ctrl+o)");
|
|
97
|
+
const statsParts = [modelLabel, statsLine, expandHint].filter(Boolean);
|
|
98
|
+
if (statsParts.length > 0) {
|
|
99
|
+
lines.push(statsParts.join(" "));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Task
|
|
103
|
+
if (progress.task) {
|
|
104
|
+
lines.push("");
|
|
105
|
+
lines.push(theme.fg("dim", "Task: " + progress.task));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Tool calls history
|
|
109
|
+
if (progress.toolCalls && progress.toolCalls.length > 0) {
|
|
110
|
+
lines.push("");
|
|
111
|
+
for (const call of progress.toolCalls) {
|
|
112
|
+
lines.push(theme.fg("dim", "- " + call));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Activity (current tool with spinner)
|
|
117
|
+
if (progress.currentTool) {
|
|
118
|
+
lines.push("");
|
|
119
|
+
const argsPreview = progress.currentToolArgs
|
|
120
|
+
? truncLine(progress.currentToolArgs, 60)
|
|
121
|
+
: "";
|
|
122
|
+
const durationPart = progress.currentToolStartedAt
|
|
123
|
+
? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
|
|
124
|
+
: "";
|
|
125
|
+
let line = theme.fg("syntaxFunction", progress.currentTool);
|
|
126
|
+
if (argsPreview) {
|
|
127
|
+
line += theme.fg("dim", ": " + argsPreview);
|
|
128
|
+
}
|
|
129
|
+
if (durationPart) {
|
|
130
|
+
line += theme.fg("dim", durationPart);
|
|
131
|
+
}
|
|
132
|
+
lines.push(line);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Status at bottom
|
|
136
|
+
if (progress.status === "completed") {
|
|
137
|
+
lines.push("");
|
|
138
|
+
lines.push(theme.fg("success", "✓ Done"));
|
|
139
|
+
} else if (progress.status === "failed") {
|
|
140
|
+
lines.push("");
|
|
141
|
+
if (progress.error) {
|
|
142
|
+
lines.push(theme.fg("error", "✗ " + progress.error));
|
|
143
|
+
} else {
|
|
144
|
+
lines.push(theme.fg("error", "✗ Failed"));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
text.setText(lines.join("\n"));
|
|
149
|
+
return text;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Expanded parallel streaming progress ────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
export function buildProgressExpandedText(
|
|
155
|
+
progress: SubagentProgress,
|
|
156
|
+
theme: Theme,
|
|
157
|
+
): string {
|
|
158
|
+
const lines: string[] = [];
|
|
159
|
+
|
|
160
|
+
// Stats line (same as compact view)
|
|
161
|
+
const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
|
|
162
|
+
const statsLine = buildStatsLine({
|
|
163
|
+
toolCount: progress.toolCount,
|
|
164
|
+
tokens: progress.tokens,
|
|
165
|
+
durationMs: progress.durationMs,
|
|
166
|
+
cost: progress.cost,
|
|
167
|
+
}, theme);
|
|
168
|
+
const expandHint = theme.fg("muted", "(ctrl+o)");
|
|
169
|
+
const statsParts = [modelLabel, statsLine, expandHint].filter(Boolean);
|
|
170
|
+
if (statsParts.length > 0) {
|
|
171
|
+
lines.push(statsParts.join(" "));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Task
|
|
175
|
+
if (progress.task) {
|
|
176
|
+
lines.push("");
|
|
177
|
+
lines.push(theme.fg("dim", "Task: " + progress.task));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Tool calls history
|
|
181
|
+
if (progress.toolCalls && progress.toolCalls.length > 0) {
|
|
182
|
+
lines.push("");
|
|
183
|
+
for (const call of progress.toolCalls) {
|
|
184
|
+
lines.push(theme.fg("dim", "- " + call));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Activity
|
|
189
|
+
if (progress.currentTool) {
|
|
190
|
+
const argsPreview = progress.currentToolArgs
|
|
191
|
+
? truncLine(progress.currentToolArgs, 60)
|
|
192
|
+
: "";
|
|
193
|
+
const durationPart = progress.currentToolStartedAt
|
|
194
|
+
? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
|
|
195
|
+
: "";
|
|
196
|
+
let line = theme.fg("syntaxFunction", progress.currentTool);
|
|
197
|
+
if (argsPreview) {
|
|
198
|
+
line += theme.fg("dim", ": " + argsPreview);
|
|
199
|
+
}
|
|
200
|
+
if (durationPart) {
|
|
201
|
+
line += theme.fg("dim", durationPart);
|
|
202
|
+
}
|
|
203
|
+
lines.push("");
|
|
204
|
+
lines.push(line);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Status at bottom
|
|
208
|
+
if (progress.status === "completed") {
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push(theme.fg("success", "✓ Done"));
|
|
211
|
+
} else if (progress.status === "failed") {
|
|
212
|
+
lines.push("");
|
|
213
|
+
if (progress.error) {
|
|
214
|
+
lines.push(theme.fg("error", "✗ " + progress.error));
|
|
215
|
+
} else {
|
|
216
|
+
lines.push(theme.fg("error", "✗ Failed"));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return lines.join("\n");
|
|
221
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { clampLine } from "@pi-archimedes/core/text";
|
|
2
|
+
|
|
3
|
+
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
|
+
|
|
5
|
+
export function formatTokens(n: number): string {
|
|
6
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
7
|
+
if (n >= 1_000) return Math.round(n / 1_000) + "k";
|
|
8
|
+
return String(n);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatDuration(ms: number): string {
|
|
12
|
+
if (ms < 1000) return "0s";
|
|
13
|
+
const seconds = Math.floor(ms / 1000);
|
|
14
|
+
if (seconds < 60) return seconds + "s";
|
|
15
|
+
const minutes = Math.floor(seconds / 60);
|
|
16
|
+
const remaining = seconds % 60;
|
|
17
|
+
return minutes + "m" + (remaining > 0 ? remaining + "s" : "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatCost(cost: number): string {
|
|
21
|
+
if (cost === 0) return "";
|
|
22
|
+
if (cost < 0.01) return "$" + cost.toFixed(4);
|
|
23
|
+
return "$" + cost.toFixed(2);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function truncLine(text: string, width: number): string {
|
|
27
|
+
return clampLine(text, width);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StatsData {
|
|
31
|
+
turns?: number;
|
|
32
|
+
toolCount?: number;
|
|
33
|
+
tokens?: number;
|
|
34
|
+
durationMs?: number;
|
|
35
|
+
cost?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StatsTheme {
|
|
39
|
+
fg: (token: string, text: string) => string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildStatsLine(
|
|
43
|
+
data: StatsData,
|
|
44
|
+
theme: StatsTheme,
|
|
45
|
+
): string {
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
const turns = data.turns ?? 0;
|
|
48
|
+
const tools = data.toolCount ?? 0;
|
|
49
|
+
const tokens = data.tokens ?? 0;
|
|
50
|
+
const duration = data.durationMs ?? 0;
|
|
51
|
+
const cost = data.cost ?? 0;
|
|
52
|
+
|
|
53
|
+
if (turns > 0) parts.push("⟳ " + turns);
|
|
54
|
+
if (tools > 0) parts.push(tools + " tool" + (tools !== 1 ? "s" : ""));
|
|
55
|
+
if (tokens > 0) parts.push(formatTokens(tokens) + " tok");
|
|
56
|
+
if (duration > 0) parts.push(formatDuration(duration));
|
|
57
|
+
if (cost > 0) parts.push(formatCost(cost));
|
|
58
|
+
|
|
59
|
+
return parts.map(p => theme.fg("dim", "· " + p)).join(" ");
|
|
60
|
+
}
|
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { StreamState } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface JsonEvent {
|
|
4
|
+
type: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract a short args preview from tool arguments.
|
|
10
|
+
* Generic: no hardcoded tool names.
|
|
11
|
+
*/
|
|
12
|
+
export function extractArgsPreview(args: unknown): string {
|
|
13
|
+
if (typeof args === "string") return args.slice(0, 120);
|
|
14
|
+
if (args && typeof args === "object" && !Array.isArray(args)) {
|
|
15
|
+
const obj = args as Record<string, unknown>;
|
|
16
|
+
const keys = Object.keys(obj);
|
|
17
|
+
// Single-key object: just show the value
|
|
18
|
+
if (keys.length === 1) {
|
|
19
|
+
const v = obj[keys[0]];
|
|
20
|
+
if (typeof v === "string") return v.slice(0, 120);
|
|
21
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
22
|
+
}
|
|
23
|
+
// Multi-key: find the longest string value (likely the main payload)
|
|
24
|
+
let best: string | undefined;
|
|
25
|
+
for (const v of Object.values(obj)) {
|
|
26
|
+
if (typeof v === "string" && v.length > (best?.length ?? 0)) {
|
|
27
|
+
best = v;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (best) return best.slice(0, 120);
|
|
31
|
+
}
|
|
32
|
+
return JSON.stringify(args)?.slice(0, 120) ?? "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Handle a tool_execution_start event.
|
|
37
|
+
*/
|
|
38
|
+
export function handleToolStart(state: StreamState, event: JsonEvent): void {
|
|
39
|
+
state.toolCount++;
|
|
40
|
+
state.currentTool = event.toolName as string;
|
|
41
|
+
state.currentToolArgs = JSON.stringify(event.args);
|
|
42
|
+
state.currentToolStartedAt = Date.now();
|
|
43
|
+
// Record tool call with args preview
|
|
44
|
+
const argsPreview = extractArgsPreview(event.args);
|
|
45
|
+
state.toolCalls.push(`${state.currentTool}: ${argsPreview}`);
|
|
46
|
+
if (state.toolCalls.length > 50) {
|
|
47
|
+
state.toolCalls.splice(0, state.toolCalls.length - 50);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Handle a tool_execution_end event.
|
|
53
|
+
*/
|
|
54
|
+
export function handleToolEnd(state: StreamState): void {
|
|
55
|
+
state.currentTool = undefined;
|
|
56
|
+
state.currentToolArgs = undefined;
|
|
57
|
+
state.currentToolStartedAt = undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Handle a tool_result_end event — capture tool output for live display.
|
|
62
|
+
*/
|
|
63
|
+
export function handleToolResult(state: StreamState, event: JsonEvent): void {
|
|
64
|
+
const toolMessage = event.message as Record<string, unknown> | undefined;
|
|
65
|
+
if (!toolMessage || toolMessage.role !== "toolResult") return;
|
|
66
|
+
|
|
67
|
+
const toolContent = toolMessage.content as Array<Record<string, unknown>> | string | undefined;
|
|
68
|
+
const toolName = (toolMessage.toolName as string) ?? "tool";
|
|
69
|
+
|
|
70
|
+
if (typeof toolContent === "string" && toolContent.trim()) {
|
|
71
|
+
const lines = toolContent.split("\n").filter((l) => l.trim());
|
|
72
|
+
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, 120)}`);
|
|
73
|
+
} else if (Array.isArray(toolContent)) {
|
|
74
|
+
for (const part of toolContent) {
|
|
75
|
+
if (part.type === "text" && (part.text as string)?.trim()) {
|
|
76
|
+
const text = part.text as string;
|
|
77
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
78
|
+
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, 120)}`);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (state.recentOutput.length > 50) {
|
|
85
|
+
state.recentOutput.splice(0, state.recentOutput.length - 50);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Handle a message_end event — extract usage and text from assistant messages.
|
|
91
|
+
*/
|
|
92
|
+
export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
|
|
93
|
+
const message = event.message as Record<string, unknown> | undefined;
|
|
94
|
+
if (!message || message.role !== "assistant") return;
|
|
95
|
+
|
|
96
|
+
// Capture model name
|
|
97
|
+
if (!state.model && message.model) {
|
|
98
|
+
state.model = message.model as string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Collect text output
|
|
102
|
+
const content = message.content as Array<Record<string, unknown>> | string | undefined;
|
|
103
|
+
if (typeof content === "string" && content.trim()) {
|
|
104
|
+
state.accumulatedOutput.push(content);
|
|
105
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
106
|
+
state.recentOutput.push(...lines.slice(-10));
|
|
107
|
+
} else if (Array.isArray(content)) {
|
|
108
|
+
for (const part of content) {
|
|
109
|
+
if (part.type === "text" && (part.text as string)?.trim()) {
|
|
110
|
+
const text = part.text as string;
|
|
111
|
+
state.accumulatedOutput.push(text);
|
|
112
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
113
|
+
state.recentOutput.push(...lines.slice(-10));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Cap recentOutput at 50 lines
|
|
119
|
+
if (state.recentOutput.length > 50) {
|
|
120
|
+
state.recentOutput.splice(0, state.recentOutput.length - 50);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Extract usage (turnCount tracked via turn_start in stream.ts)
|
|
124
|
+
if (message.usage) {
|
|
125
|
+
const usage = message.usage as Record<string, unknown>;
|
|
126
|
+
state.totalInput += (usage.input as number) || 0;
|
|
127
|
+
state.totalOutput += (usage.output as number) || 0;
|
|
128
|
+
state.totalCacheRead += (usage.cacheRead as number) || 0;
|
|
129
|
+
state.totalCacheWrite += (usage.cacheWrite as number) || 0;
|
|
130
|
+
const costObj = usage.cost as { total?: number } | undefined;
|
|
131
|
+
state.totalCost += costObj?.total ?? 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Handle an agent_end event — collect all assistant text for final output.
|
|
137
|
+
*/
|
|
138
|
+
export function handleAgentEnd(state: StreamState, event: JsonEvent): void {
|
|
139
|
+
const messages = event.messages as Array<Record<string, unknown>> | undefined;
|
|
140
|
+
if (!messages || messages.length === 0) return;
|
|
141
|
+
|
|
142
|
+
const allText: string[] = [];
|
|
143
|
+
for (const msg of messages) {
|
|
144
|
+
if (msg.role === "assistant") {
|
|
145
|
+
const content = msg.content as Array<Record<string, unknown>> | string | undefined;
|
|
146
|
+
if (typeof content === "string" && content.trim()) {
|
|
147
|
+
allText.push(content);
|
|
148
|
+
} else if (Array.isArray(content)) {
|
|
149
|
+
for (const part of content) {
|
|
150
|
+
if (part.type === "text" && (part.text as string)?.trim()) {
|
|
151
|
+
allText.push(part.text as string);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
state.finalOutput = allText.join("\n\n");
|
|
158
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { executeSubagent, executeParallel } from "./execute.js";
|
|
5
|
+
import { renderSubagentResult } from "./render.js";
|
|
6
|
+
import type {
|
|
7
|
+
SubagentDetails,
|
|
8
|
+
SubagentProgress,
|
|
9
|
+
SubagentResult,
|
|
10
|
+
SubagentToolResult,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
// ── JSON Schema for tool parameters (TypeBox) ──────────────────────────────
|
|
14
|
+
|
|
15
|
+
const TaskItem = Type.Object({
|
|
16
|
+
agent: Type.Optional(Type.String()),
|
|
17
|
+
task: Type.String(),
|
|
18
|
+
count: Type.Optional(Type.Number()),
|
|
19
|
+
model: Type.Optional(Type.String()),
|
|
20
|
+
cwd: Type.Optional(Type.String()),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const SUBAGENT_PARAMS_SCHEMA = Type.Object({
|
|
24
|
+
agent: Type.Optional(Type.String({
|
|
25
|
+
description: "Agent name/identifier (optional, defaults to 'general')",
|
|
26
|
+
})),
|
|
27
|
+
task: Type.Optional(Type.String({
|
|
28
|
+
description: "Task description for the subagent",
|
|
29
|
+
})),
|
|
30
|
+
tasks: Type.Optional(Type.Array(TaskItem, {
|
|
31
|
+
description: "Multiple tasks for parallel execution",
|
|
32
|
+
})),
|
|
33
|
+
model: Type.Optional(Type.String({
|
|
34
|
+
description: "Model override (e.g. 'anthropic/claude-sonnet-4')",
|
|
35
|
+
})),
|
|
36
|
+
async: Type.Optional(Type.Boolean({
|
|
37
|
+
description: "Run asynchronously (fire-and-forget)",
|
|
38
|
+
})),
|
|
39
|
+
cwd: Type.Optional(Type.String({
|
|
40
|
+
description: "Working directory for the subagent",
|
|
41
|
+
})),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ── Theme helper type for render functions ──────────────────────────────────
|
|
45
|
+
|
|
46
|
+
interface RenderTheme {
|
|
47
|
+
fg: (token: string, text: string) => string;
|
|
48
|
+
bold: (text: string) => string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Tool registration ───────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export function registerSubagent(pi: ExtensionAPI): void {
|
|
54
|
+
pi.registerTool({
|
|
55
|
+
name: "subagent",
|
|
56
|
+
label: "Subagent",
|
|
57
|
+
description:
|
|
58
|
+
"Delegate tasks to subagents. Single: { agent, task }. Parallel: { tasks: [{ agent, task }] }. Options: model, cwd.",
|
|
59
|
+
parameters: SUBAGENT_PARAMS_SCHEMA,
|
|
60
|
+
|
|
61
|
+
async execute(
|
|
62
|
+
_id: string,
|
|
63
|
+
params: {
|
|
64
|
+
agent?: string;
|
|
65
|
+
task?: string;
|
|
66
|
+
tasks?: Array<{ agent?: string; task: string; count?: number; model?: string; cwd?: string }>;
|
|
67
|
+
model?: string;
|
|
68
|
+
cwd?: string;
|
|
69
|
+
async?: boolean;
|
|
70
|
+
},
|
|
71
|
+
signal: AbortSignal | undefined,
|
|
72
|
+
onUpdate: ((update: SubagentToolResult) => void) | undefined,
|
|
73
|
+
_ctx: ExtensionContext,
|
|
74
|
+
): Promise<SubagentToolResult> {
|
|
75
|
+
// Parallel mode
|
|
76
|
+
if (params.tasks && params.tasks.length > 0) {
|
|
77
|
+
const results: SubagentResult[] = await executeParallel({
|
|
78
|
+
tasks: params.tasks,
|
|
79
|
+
signal,
|
|
80
|
+
onUpdate: (progress: SubagentProgress[]) => {
|
|
81
|
+
onUpdate?.({
|
|
82
|
+
content: [],
|
|
83
|
+
details: {
|
|
84
|
+
mode: "parallel",
|
|
85
|
+
results: [],
|
|
86
|
+
progress,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: "text", text: formatResultsSummary(results) }],
|
|
94
|
+
details: {
|
|
95
|
+
mode: "parallel",
|
|
96
|
+
results,
|
|
97
|
+
progress: results.map(r => r.progress).filter(Boolean) as SubagentProgress[] | undefined,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Single mode
|
|
103
|
+
if (params.task) {
|
|
104
|
+
const result: SubagentResult = await executeSubagent({
|
|
105
|
+
agent: params.agent,
|
|
106
|
+
task: params.task,
|
|
107
|
+
model: params.model,
|
|
108
|
+
cwd: params.cwd,
|
|
109
|
+
signal,
|
|
110
|
+
onUpdate: (progress: SubagentProgress) => {
|
|
111
|
+
onUpdate?.({
|
|
112
|
+
content: [],
|
|
113
|
+
details: {
|
|
114
|
+
mode: "single",
|
|
115
|
+
results: [],
|
|
116
|
+
progress: [progress],
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
content: [{ type: "text", text: result.finalOutput ?? result.error ?? "completed" }],
|
|
124
|
+
details: {
|
|
125
|
+
mode: "single",
|
|
126
|
+
results: [result],
|
|
127
|
+
progress: result.progress ? [result.progress] : undefined,
|
|
128
|
+
},
|
|
129
|
+
isError: result.exitCode !== 0,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
content: [{ type: "text", text: "Missing task parameter" }],
|
|
135
|
+
details: {
|
|
136
|
+
mode: "single",
|
|
137
|
+
results: [],
|
|
138
|
+
},
|
|
139
|
+
isError: true,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
renderCall(args: unknown, theme: Theme, ctx: unknown): import("@earendil-works/pi-tui").Component {
|
|
144
|
+
const params = args as Record<string, unknown> | undefined;
|
|
145
|
+
const tasks = params?.tasks as Array<unknown> | undefined;
|
|
146
|
+
const agent = params?.agent as string | undefined;
|
|
147
|
+
|
|
148
|
+
const lastComponent = (ctx as { lastComponent?: import("@earendil-works/pi-tui").Component })?.lastComponent;
|
|
149
|
+
const text = (lastComponent instanceof Text ? lastComponent : new Text("", 0, 0)) as Text;
|
|
150
|
+
(ctx as Record<string, unknown>).lastComponent = text;
|
|
151
|
+
|
|
152
|
+
if (tasks && tasks.length > 0) {
|
|
153
|
+
const label = theme.fg("toolTitle", theme.bold("subagent")) + " " + tasks.length + " tasks";
|
|
154
|
+
text.setText(label);
|
|
155
|
+
} else if (agent) {
|
|
156
|
+
const label = theme.fg("toolTitle", theme.bold("subagent")) + " " + theme.fg("accent", agent);
|
|
157
|
+
text.setText(label);
|
|
158
|
+
} else {
|
|
159
|
+
text.setText(theme.fg("toolTitle", theme.bold("subagent")));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return text;
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
renderResult(result: unknown, options: unknown, theme: Theme, context: unknown): import("@earendil-works/pi-tui").Component {
|
|
166
|
+
const toolResult = result as unknown as SubagentToolResult;
|
|
167
|
+
const debugText = new Text("", 0, 0);
|
|
168
|
+
const expanded = ((context as Record<string, unknown>)?.expanded ??
|
|
169
|
+
(options as Record<string, unknown>)?.expanded ??
|
|
170
|
+
false) as boolean;
|
|
171
|
+
|
|
172
|
+
const renderTheme = theme as unknown as RenderTheme;
|
|
173
|
+
const text = new Text("", 0, 0);
|
|
174
|
+
const renderContext = {
|
|
175
|
+
expanded,
|
|
176
|
+
isError: toolResult.isError ?? false,
|
|
177
|
+
lastComponent: (context as { lastComponent?: Text })?.lastComponent,
|
|
178
|
+
state: (context as Record<string, unknown>)?.state ?? {},
|
|
179
|
+
invalidate: () => {},
|
|
180
|
+
};
|
|
181
|
+
(context as Record<string, unknown>).lastComponent = text;
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const rendered = renderSubagentResult(text, toolResult, { expanded }, renderTheme, renderContext as any);
|
|
185
|
+
return rendered;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
debugText.setText("render error: " + (e instanceof Error ? e.message : String(e)));
|
|
188
|
+
return debugText;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
function formatProgressSummary(progress: SubagentProgress[]): string {
|
|
197
|
+
if (progress.length === 0) return "";
|
|
198
|
+
const lines = progress.map((p) => {
|
|
199
|
+
const tool = p.currentTool ? ` [${p.currentTool}]` : "";
|
|
200
|
+
const stats = [
|
|
201
|
+
p.toolCount > 0 ? p.toolCount + " tools" : "",
|
|
202
|
+
p.tokens > 0 ? Math.round(p.tokens / 1000) + "k tok" : "",
|
|
203
|
+
].filter(Boolean).join(" · ");
|
|
204
|
+
return p.agent + tool + (stats ? " " + stats : "");
|
|
205
|
+
});
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function formatResultsSummary(results: SubagentResult[]): string {
|
|
210
|
+
const lines = results.map((r) => {
|
|
211
|
+
const status = r.exitCode === 0 ? "✓" : "✗";
|
|
212
|
+
const summary = r.progressSummary
|
|
213
|
+
? `${r.progressSummary.toolCount} tools · ${Math.round(r.progressSummary.tokens / 1000)}k tok · ${Math.round(r.progressSummary.durationMs / 1000)}s`
|
|
214
|
+
: "";
|
|
215
|
+
return `${status} ${r.agent}${summary ? " " + summary : ""}`;
|
|
216
|
+
});
|
|
217
|
+
return lines.join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Default export ──────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
export default function (pi: ExtensionAPI): void {
|
|
223
|
+
registerSubagent(pi);
|
|
224
|
+
}
|