@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 ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@pi-archimedes/subagent",
3
+ "version": "0.3.1",
4
+ "type": "module",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "description": "Subagent dispatch with live TUI streaming and cost tracking",
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "main": "./src/index.ts",
13
+ "dependencies": {
14
+ "@pi-archimedes/core": "0.3.1"
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": ">=0.1.0",
18
+ "@earendil-works/pi-tui": ">=0.1.0",
19
+ "typebox": ">=1.1.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.0.0",
23
+ "typebox": "^1.1.38",
24
+ "typescript": "^6.0.0"
25
+ },
26
+ "pi": {
27
+ "extensions": [
28
+ "./src/index.ts"
29
+ ]
30
+ }
31
+ }
package/src/compact.ts ADDED
@@ -0,0 +1,360 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import type { SubagentDetails, SubagentProgress, SubagentResult, SubagentToolResult } from "./types.js";
3
+ import { SPINNER_FRAMES, formatTokens, formatDuration, formatCost, truncLine, buildStatsLine } from "./format.js";
4
+
5
+ type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
6
+ type RenderContext = { state: Record<string, unknown>; invalidate: () => void };
7
+
8
+ // ── Activity line builder ───────────────────────────────────────────────────
9
+
10
+ export function buildActivityLine(
11
+ progress: {
12
+ currentTool?: string;
13
+ currentToolArgs?: string;
14
+ currentToolStartedAt?: number;
15
+ finalOutput?: string;
16
+ status?: "running" | "completed" | "failed";
17
+ error?: string;
18
+ },
19
+ theme: Theme,
20
+ ): string {
21
+ const prefix = theme.fg("dim", " ⎿ ");
22
+
23
+ if (progress.error) {
24
+ return prefix + theme.fg("error", truncLine(progress.error, 80));
25
+ }
26
+
27
+ if (progress.currentTool) {
28
+ const argsPreview = progress.currentToolArgs
29
+ ? truncLine(progress.currentToolArgs, 60)
30
+ : "";
31
+ const durationPart = progress.currentToolStartedAt
32
+ ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
33
+ : "";
34
+ let line = theme.fg("syntaxFunction", progress.currentTool);
35
+ if (argsPreview) {
36
+ line += theme.fg("dim", ": " + argsPreview);
37
+ }
38
+ if (durationPart) {
39
+ line += theme.fg("dim", durationPart);
40
+ }
41
+ return prefix + line;
42
+ }
43
+
44
+ if (progress.finalOutput) {
45
+ const firstLine = progress.finalOutput.split("\n")[0] ?? "";
46
+ return prefix + truncLine(firstLine, 80);
47
+ }
48
+
49
+ return "";
50
+ }
51
+
52
+ // ── Spinner helper ──────────────────────────────────────────────────────────
53
+
54
+ function getSpinnerGlyph(agentName: string, isRunning: boolean, status: string, context: RenderContext): string {
55
+ if (isRunning) {
56
+ const timerKey = "_subagentTimer_" + agentName;
57
+ const frameKey = "_subagentFrame_" + agentName;
58
+ if (!context.state[timerKey]) {
59
+ context.state[frameKey] = 0;
60
+ const timer = setInterval(() => {
61
+ context.state[frameKey] = ((context.state[frameKey] as number) + 1) % SPINNER_FRAMES.length;
62
+ context.invalidate();
63
+ }, 80);
64
+ context.state[timerKey] = timer;
65
+ }
66
+ return SPINNER_FRAMES[context.state[frameKey] as number];
67
+ }
68
+ return status === "completed" ? "✓" : "✗";
69
+ }
70
+
71
+ function cleanupTimer(agentName: string, context: RenderContext) {
72
+ const timerKey = "_subagentTimer_" + agentName;
73
+ const frameKey = "_subagentFrame_" + agentName;
74
+ if (context.state[timerKey]) {
75
+ clearInterval(context.state[timerKey] as ReturnType<typeof setInterval>);
76
+ delete context.state[timerKey];
77
+ }
78
+ delete context.state[frameKey];
79
+ }
80
+
81
+ // ── Compact single agent ────────────────────────────────────────────────────
82
+
83
+ export function renderCompactSingle(
84
+ text: Text,
85
+ result: SubagentResult,
86
+ progress: SubagentProgress | undefined,
87
+ theme: Theme,
88
+ context: RenderContext,
89
+ ): Text {
90
+ const agentName = result.agent ?? "subagent";
91
+ const summary = result.progressSummary ?? { toolCount: 0, tokens: 0, durationMs: 0 };
92
+ const isRunning = progress?.status === "running";
93
+ const status = isRunning ? "running" : (result.exitCode === 0 ? "completed" : "failed");
94
+
95
+ // Manage timer for spinner on line 3
96
+ if (isRunning) {
97
+ getSpinnerGlyph(agentName, true, "running", context);
98
+ } else {
99
+ cleanupTimer(agentName, context);
100
+ }
101
+
102
+ // Track start time for live duration
103
+ const timeKey = "_subagentStartTime_" + agentName;
104
+ if (isRunning && !context.state[timeKey]) {
105
+ // Estimate start time from current duration
106
+ const currentDuration = summary.durationMs || (progress?.durationMs ?? 0);
107
+ context.state[timeKey] = Date.now() - currentDuration;
108
+ }
109
+ if (!isRunning) {
110
+ delete context.state[timeKey];
111
+ }
112
+ const liveDuration = isRunning && context.state[timeKey]
113
+ ? Date.now() - (context.state[timeKey] as number)
114
+ : summary.durationMs;
115
+
116
+ const statsData = {
117
+ turns: result.usage.turns ?? 0,
118
+ toolCount: summary.toolCount,
119
+ tokens: summary.tokens,
120
+ durationMs: liveDuration,
121
+ cost: result.usage.cost ?? 0,
122
+ };
123
+ const statsLine = buildStatsLine(statsData, theme);
124
+
125
+ const statsPart = statsLine ?? "";
126
+
127
+ // Activity: spinner + current tool if running, status if finished
128
+ let activityLine: string;
129
+ if (isRunning) {
130
+ const spinner = SPINNER_FRAMES[(context.state["_subagentFrame_" + agentName] as number) ?? 0];
131
+ const spinnerColored = theme.fg("muted", spinner);
132
+ if (progress?.currentTool) {
133
+ const argsPreview = progress.currentToolArgs
134
+ ? truncLine(progress.currentToolArgs, 60)
135
+ : "";
136
+ const durationPart = progress.currentToolStartedAt
137
+ ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
138
+ : "";
139
+ let line = theme.fg("syntaxFunction", progress.currentTool);
140
+ if (argsPreview) {
141
+ line += theme.fg("dim", ": " + argsPreview);
142
+ }
143
+ if (durationPart) {
144
+ line += theme.fg("dim", durationPart);
145
+ }
146
+ activityLine = spinnerColored + " " + line;
147
+ } else if (progress?.toolCalls && progress.toolCalls.length > 0) {
148
+ const lastCall = progress.toolCalls[progress.toolCalls.length - 1];
149
+ activityLine = theme.fg("dim", " ⎿ ") + theme.fg("muted", lastCall);
150
+ } else {
151
+ activityLine = theme.fg("muted", " ⎿ Working...");
152
+ }
153
+ } else if (result.error) {
154
+ activityLine = theme.fg("error", "✗ " + truncLine(result.error, 80));
155
+ } else if (status === "completed") {
156
+ activityLine = theme.fg("success", "✓ Done");
157
+ } else {
158
+ activityLine = theme.fg("error", "✗ Failed");
159
+ }
160
+
161
+ const modelName = progress?.model ?? result.model;
162
+ const modelLabel = modelName
163
+ ? theme.fg("accent", modelName)
164
+ : "";
165
+ const expandHint = theme.fg("muted", "(ctrl+o)");
166
+ let output = [modelLabel, statsPart, expandHint].filter(Boolean).join(" ");
167
+ output += "\n" + activityLine;
168
+
169
+ text.setText(output);
170
+ return text;
171
+ }
172
+
173
+ // ── Compact parallel agents ─────────────────────────────────────────────────
174
+
175
+ export function renderCompactParallel(
176
+ text: Text,
177
+ details: SubagentDetails,
178
+ theme: Theme,
179
+ context: RenderContext,
180
+ ): Text {
181
+ const lines = details.results.map((result, i) => {
182
+ const progress = details.progress?.[i];
183
+ const agentName = result.agent ?? "agent-" + i;
184
+ const summary = result.progressSummary ?? { toolCount: 0, tokens: 0, durationMs: 0 };
185
+ const status = progress?.status ?? (result.exitCode === 0 ? "completed" : "failed");
186
+
187
+ let glyph = status === "completed" ? "✓" : status === "failed" ? "✗" : "⠋";
188
+ const glyphColored = status === "completed"
189
+ ? theme.fg("success", glyph)
190
+ : status === "failed"
191
+ ? theme.fg("error", glyph)
192
+ : theme.fg("muted", glyph);
193
+
194
+ const statsData = {
195
+ turns: result.usage.turns ?? 0,
196
+ toolCount: summary.toolCount,
197
+ tokens: summary.tokens,
198
+ durationMs: summary.durationMs,
199
+ cost: result.usage.cost ?? 0,
200
+ };
201
+ const statsLine = buildStatsLine(statsData, theme);
202
+ const statsPart = statsLine ? " " + statsLine : "";
203
+
204
+ const activityData = {
205
+ currentTool: progress?.currentTool,
206
+ currentToolArgs: progress?.currentToolArgs,
207
+ currentToolStartedAt: progress?.currentToolStartedAt,
208
+ finalOutput: result.finalOutput,
209
+ status,
210
+ error: result.error,
211
+ };
212
+ const activityLine = buildActivityLine(activityData, theme);
213
+
214
+ let line = `${glyphColored} ${agentName}${statsPart}`;
215
+ if (activityLine) {
216
+ line += "\n" + activityLine;
217
+ }
218
+ return line;
219
+ });
220
+
221
+ text.setText(lines.join("\n"));
222
+ return text;
223
+ }
224
+
225
+ // ── Compact streaming progress ──────────────────────────────────────────────
226
+
227
+ export function renderCompactProgress(
228
+ text: Text,
229
+ progress: SubagentProgress,
230
+ theme: Theme,
231
+ context: RenderContext,
232
+ ): Text {
233
+ const agentName = progress.agent ?? "subagent";
234
+ const status = progress.status;
235
+ const isRunning = status === "running";
236
+
237
+ let glyph = getSpinnerGlyph(agentName, isRunning, status, context);
238
+ const glyphColored = status === "completed"
239
+ ? theme.fg("success", glyph)
240
+ : status === "failed"
241
+ ? theme.fg("error", glyph)
242
+ : theme.fg("muted", glyph);
243
+
244
+ // Track start time for live duration
245
+ const timeKey = "_subagentStartTime_" + agentName;
246
+ if (isRunning && !context.state[timeKey]) {
247
+ context.state[timeKey] = Date.now() - (progress.durationMs ?? 0);
248
+ }
249
+ if (!isRunning) {
250
+ delete context.state[timeKey];
251
+ }
252
+ const liveDuration = isRunning && context.state[timeKey]
253
+ ? Date.now() - (context.state[timeKey] as number)
254
+ : progress.durationMs;
255
+
256
+ const statsData = {
257
+ turns: 0,
258
+ toolCount: progress.toolCount,
259
+ tokens: progress.tokens,
260
+ durationMs: liveDuration,
261
+ cost: progress.cost,
262
+ };
263
+ const statsLine = buildStatsLine(statsData, theme);
264
+
265
+ // Activity: current tool if running, status if finished
266
+ let activityLine: string;
267
+ if (isRunning) {
268
+ const spinner = SPINNER_FRAMES[(context.state["_subagentFrame_" + agentName] as number) ?? 0];
269
+ const spinnerColored = theme.fg("muted", spinner);
270
+ if (progress.currentTool) {
271
+ const argsPreview = progress.currentToolArgs
272
+ ? truncLine(progress.currentToolArgs, 60)
273
+ : "";
274
+ const durationPart = progress.currentToolStartedAt
275
+ ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
276
+ : "";
277
+ let line = theme.fg("syntaxFunction", progress.currentTool);
278
+ if (argsPreview) {
279
+ line += theme.fg("dim", ": " + argsPreview);
280
+ }
281
+ if (durationPart) {
282
+ line += theme.fg("dim", durationPart);
283
+ }
284
+ activityLine = spinnerColored + " " + line;
285
+ } else if (progress.toolCalls && progress.toolCalls.length > 0) {
286
+ const lastCall = progress.toolCalls[progress.toolCalls.length - 1];
287
+ activityLine = spinnerColored + " " + theme.fg("muted", lastCall);
288
+ } else {
289
+ activityLine = spinnerColored + " " + theme.fg("muted", "Working...");
290
+ }
291
+ } else if (progress.error) {
292
+ activityLine = theme.fg("error", "✗ " + truncLine(progress.error, 80));
293
+ } else if (status === "completed") {
294
+ activityLine = theme.fg("success", "✓ Done");
295
+ } else {
296
+ activityLine = theme.fg("error", "✗ Failed");
297
+ }
298
+
299
+ const modelLabel = progress.model
300
+ ? theme.fg("accent", progress.model)
301
+ : "";
302
+ const statsPart = statsLine ?? "";
303
+ const expandHint = theme.fg("muted", "(ctrl+o)");
304
+ let output = [modelLabel, statsPart, expandHint].filter(Boolean).join(" ");
305
+ output += "\n" + activityLine;
306
+
307
+ text.setText(output);
308
+ return text;
309
+ }
310
+
311
+ // ── Compact parallel streaming progress ─────────────────────────────────────
312
+
313
+ export function renderCompactParallelProgress(
314
+ text: Text,
315
+ details: SubagentDetails,
316
+ theme: Theme,
317
+ context: RenderContext,
318
+ ): Text {
319
+ const lines = (details.progress ?? []).map((progress, i) => {
320
+ const agentName = progress.agent ?? "agent-" + i;
321
+ const status = progress.status;
322
+ const isRunning = status === "running";
323
+
324
+ let glyph = getSpinnerGlyph(agentName, isRunning, status, context);
325
+ const glyphColored = status === "completed"
326
+ ? theme.fg("success", glyph)
327
+ : status === "failed"
328
+ ? theme.fg("error", glyph)
329
+ : theme.fg("muted", glyph);
330
+
331
+ const statsData = {
332
+ turns: 0,
333
+ toolCount: progress.toolCount,
334
+ tokens: progress.tokens,
335
+ durationMs: progress.durationMs,
336
+ cost: progress.cost,
337
+ };
338
+ const statsLine = buildStatsLine(statsData, theme);
339
+ const statsPart = statsLine ? " " + statsLine : "";
340
+
341
+ const activityData = {
342
+ currentTool: progress.currentTool,
343
+ currentToolArgs: progress.currentToolArgs,
344
+ currentToolStartedAt: progress.currentToolStartedAt,
345
+ finalOutput: undefined,
346
+ status,
347
+ error: progress.error,
348
+ };
349
+ const activityLine = buildActivityLine(activityData, theme);
350
+
351
+ let line = `${glyphColored} ${agentName}${statsPart}`;
352
+ if (activityLine) {
353
+ line += "\n" + activityLine;
354
+ }
355
+ return line;
356
+ });
357
+
358
+ text.setText(lines.join("\n"));
359
+ return text;
360
+ }
package/src/cost.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { getBus, Events } from "@pi-archimedes/core/bus";
2
+
3
+ export function emitCostUpdate(agent: string, usage: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; cost?: number }): void {
4
+ getBus().emit(Events.COST_UPDATE, {
5
+ source: `subagent:${agent}`,
6
+ ...usage,
7
+ });
8
+ }
package/src/execute.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { spawnSubagent } from "./spawn.js";
2
+ import { streamEvents } from "./stream.js";
3
+ import { emitCostUpdate } from "./cost.js";
4
+ import type { SubagentProgress, SubagentResult, SubagentUsage } from "./types.js";
5
+
6
+ export interface ExecuteOptions {
7
+ agent?: string;
8
+ task: string;
9
+ model?: string;
10
+ cwd?: string;
11
+ signal?: AbortSignal;
12
+ onUpdate?: (progress: SubagentProgress) => void;
13
+ }
14
+
15
+ /**
16
+ * Execute a single subagent synchronously — blocks until completion.
17
+ */
18
+ export async function executeSubagent(options: ExecuteOptions): Promise<SubagentResult> {
19
+ const agentName = options.agent ?? "subagent";
20
+ const startTime = Date.now();
21
+
22
+ const child = spawnSubagent({
23
+ task: options.task,
24
+ model: options.model,
25
+ cwd: options.cwd,
26
+ signal: options.signal,
27
+ });
28
+
29
+ // Track previously emitted values to only emit deltas
30
+ let lastEmittedInput = 0;
31
+ let lastEmittedOutput = 0;
32
+ let lastEmittedCost = 0;
33
+
34
+ try {
35
+ const result = await streamEvents(child, {
36
+ agent: agentName,
37
+ task: options.task,
38
+ onProgress: (progress: SubagentProgress) => {
39
+ // Emit only deltas to avoid double-counting in CostAccumulator
40
+ const deltaInput = progress.inputTokens - lastEmittedInput;
41
+ const deltaOutput = progress.outputTokens - lastEmittedOutput;
42
+ const deltaCost = progress.cost - lastEmittedCost;
43
+ if (deltaInput > 0 || deltaOutput > 0 || deltaCost > 0) {
44
+ emitCostUpdate(agentName, {
45
+ inputTokens: deltaInput,
46
+ outputTokens: deltaOutput,
47
+ cost: deltaCost,
48
+ });
49
+ lastEmittedInput = progress.inputTokens;
50
+ lastEmittedOutput = progress.outputTokens;
51
+ lastEmittedCost = progress.cost;
52
+ }
53
+ options.onUpdate?.(progress);
54
+ },
55
+ });
56
+
57
+ // Enrich result with agent name and duration
58
+ const durationMs = Date.now() - startTime;
59
+ return {
60
+ ...result,
61
+ agent: agentName,
62
+ task: options.task,
63
+ progress: result.progress
64
+ ? { ...result.progress, agent: agentName, durationMs }
65
+ : undefined,
66
+ progressSummary: result.progressSummary
67
+ ? { ...result.progressSummary, durationMs }
68
+ : { toolCount: 0, tokens: 0, durationMs },
69
+ };
70
+ } catch (err) {
71
+ return {
72
+ agent: agentName,
73
+ task: options.task,
74
+ exitCode: 1,
75
+ usage: {
76
+ input: 0,
77
+ output: 0,
78
+ cacheRead: 0,
79
+ cacheWrite: 0,
80
+ cost: 0,
81
+ turns: 0,
82
+ } as SubagentUsage,
83
+ error: err instanceof Error ? err.message : String(err),
84
+ progressSummary: { toolCount: 0, tokens: 0, durationMs: Date.now() - startTime },
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Execute multiple subagents in parallel.
91
+ */
92
+ export async function executeParallel(options: {
93
+ tasks: Array<{ agent?: string; task: string; count?: number; model?: string; cwd?: string }>;
94
+ signal?: AbortSignal;
95
+ onUpdate?: (progress: SubagentProgress[]) => void;
96
+ }): Promise<SubagentResult[]> {
97
+ const results = await Promise.all(
98
+ options.tasks.map((taskDef) =>
99
+ executeSubagent({
100
+ ...taskDef,
101
+ signal: options.signal,
102
+ onUpdate: (progress: SubagentProgress) => {
103
+ // Collect all parallel progress updates
104
+ // This is a simplified approach — each task fires independently
105
+ options.onUpdate?.([progress]);
106
+ },
107
+ }),
108
+ ),
109
+ );
110
+ return results;
111
+ }