pi-subagents 0.3.0 → 0.3.2

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/execution.ts ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Core execution logic for running subagents
3
+ */
4
+
5
+ import { spawn } from "node:child_process";
6
+ import * as fs from "node:fs";
7
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
8
+ import type { Message } from "@mariozechner/pi-ai";
9
+ import type { AgentConfig } from "./agents.js";
10
+ import {
11
+ appendJsonl,
12
+ ensureArtifactsDir,
13
+ getArtifactPaths,
14
+ writeArtifact,
15
+ writeMetadata,
16
+ } from "./artifacts.js";
17
+ import {
18
+ type AgentProgress,
19
+ type ArtifactPaths,
20
+ type Details,
21
+ type MaxOutputConfig,
22
+ type RunSyncOptions,
23
+ type SingleResult,
24
+ DEFAULT_MAX_OUTPUT,
25
+ truncateOutput,
26
+ } from "./types.js";
27
+ import {
28
+ writePrompt,
29
+ getFinalOutput,
30
+ findLatestSessionFile,
31
+ detectSubagentError,
32
+ extractToolArgsPreview,
33
+ extractTextFromContent,
34
+ } from "./utils.js";
35
+
36
+ /**
37
+ * Run a subagent synchronously (blocking until complete)
38
+ */
39
+ export async function runSync(
40
+ runtimeCwd: string,
41
+ agents: AgentConfig[],
42
+ agentName: string,
43
+ task: string,
44
+ options: RunSyncOptions,
45
+ ): Promise<SingleResult> {
46
+ const { cwd, signal, onUpdate, maxOutput, artifactsDir, artifactConfig, runId, index } = options;
47
+ const agent = agents.find((a) => a.name === agentName);
48
+ if (!agent) {
49
+ return {
50
+ agent: agentName,
51
+ task,
52
+ exitCode: 1,
53
+ messages: [],
54
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
55
+ error: `Unknown agent: ${agentName}`,
56
+ };
57
+ }
58
+
59
+ const args = ["--mode", "json", "-p"];
60
+ const shareEnabled = options.share === true;
61
+ const sessionEnabled = Boolean(options.sessionDir) || shareEnabled;
62
+ if (!sessionEnabled) {
63
+ args.push("--no-session");
64
+ }
65
+ if (options.sessionDir) {
66
+ try {
67
+ fs.mkdirSync(options.sessionDir, { recursive: true });
68
+ } catch {}
69
+ args.push("--session-dir", options.sessionDir);
70
+ }
71
+ if (agent.model) args.push("--model", agent.model);
72
+ if (agent.tools?.length) {
73
+ const builtinTools: string[] = [];
74
+ const extensionPaths: string[] = [];
75
+ for (const tool of agent.tools) {
76
+ if (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")) {
77
+ extensionPaths.push(tool);
78
+ } else {
79
+ builtinTools.push(tool);
80
+ }
81
+ }
82
+ if (builtinTools.length > 0) {
83
+ args.push("--tools", builtinTools.join(","));
84
+ }
85
+ for (const extPath of extensionPaths) {
86
+ args.push("--extension", extPath);
87
+ }
88
+ }
89
+
90
+ let tmpDir: string | null = null;
91
+ if (agent.systemPrompt?.trim()) {
92
+ const tmp = writePrompt(agent.name, agent.systemPrompt);
93
+ tmpDir = tmp.dir;
94
+ args.push("--append-system-prompt", tmp.path);
95
+ }
96
+ args.push(`Task: ${task}`);
97
+
98
+ const result: SingleResult = {
99
+ agent: agentName,
100
+ task,
101
+ exitCode: 0,
102
+ messages: [],
103
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
104
+ };
105
+
106
+ const progress: AgentProgress = {
107
+ index: index ?? 0,
108
+ agent: agentName,
109
+ status: "running",
110
+ task,
111
+ recentTools: [],
112
+ recentOutput: [],
113
+ toolCount: 0,
114
+ tokens: 0,
115
+ durationMs: 0,
116
+ };
117
+ result.progress = progress;
118
+
119
+ const startTime = Date.now();
120
+ const jsonlLines: string[] = [];
121
+
122
+ let artifactPathsResult: ArtifactPaths | undefined;
123
+ if (artifactsDir && artifactConfig?.enabled !== false) {
124
+ artifactPathsResult = getArtifactPaths(artifactsDir, runId, agentName, index);
125
+ ensureArtifactsDir(artifactsDir);
126
+ if (artifactConfig?.includeInput !== false) {
127
+ writeArtifact(artifactPathsResult.inputPath, `# Task for ${agentName}\n\n${task}`);
128
+ }
129
+ }
130
+
131
+ const exitCode = await new Promise<number>((resolve) => {
132
+ const proc = spawn("pi", args, { cwd: cwd ?? runtimeCwd, stdio: ["ignore", "pipe", "pipe"] });
133
+ let buf = "";
134
+
135
+ // Throttled update mechanism - consolidates all updates
136
+ let lastUpdateTime = 0;
137
+ let updatePending = false;
138
+ let pendingTimer: ReturnType<typeof setTimeout> | null = null;
139
+ let processClosed = false;
140
+ const UPDATE_THROTTLE_MS = 50; // Reduced from 75ms for faster responsiveness
141
+
142
+ const scheduleUpdate = () => {
143
+ if (!onUpdate || processClosed) return;
144
+ const now = Date.now();
145
+ const elapsed = now - lastUpdateTime;
146
+
147
+ if (elapsed >= UPDATE_THROTTLE_MS) {
148
+ // Enough time passed, update immediately
149
+ // Clear any pending timer to avoid double-updates
150
+ if (pendingTimer) {
151
+ clearTimeout(pendingTimer);
152
+ pendingTimer = null;
153
+ }
154
+ lastUpdateTime = now;
155
+ updatePending = false;
156
+ progress.durationMs = now - startTime;
157
+ onUpdate({
158
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
159
+ details: { mode: "single", results: [result], progress: [progress] },
160
+ });
161
+ } else if (!updatePending) {
162
+ // Schedule update for later
163
+ updatePending = true;
164
+ pendingTimer = setTimeout(() => {
165
+ pendingTimer = null;
166
+ if (updatePending && !processClosed) {
167
+ updatePending = false;
168
+ lastUpdateTime = Date.now();
169
+ progress.durationMs = Date.now() - startTime;
170
+ onUpdate({
171
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
172
+ details: { mode: "single", results: [result], progress: [progress] },
173
+ });
174
+ }
175
+ }, UPDATE_THROTTLE_MS - elapsed);
176
+ }
177
+ };
178
+
179
+ const processLine = (line: string) => {
180
+ if (!line.trim()) return;
181
+ jsonlLines.push(line);
182
+ try {
183
+ const evt = JSON.parse(line) as { type?: string; message?: Message; toolName?: string; args?: unknown };
184
+ const now = Date.now();
185
+ progress.durationMs = now - startTime;
186
+
187
+ if (evt.type === "tool_execution_start") {
188
+ progress.toolCount++;
189
+ progress.currentTool = evt.toolName;
190
+ progress.currentToolArgs = extractToolArgsPreview((evt.args || {}) as Record<string, unknown>);
191
+ // Tool start is important - update immediately by forcing throttle reset
192
+ lastUpdateTime = 0;
193
+ scheduleUpdate();
194
+ }
195
+
196
+ if (evt.type === "tool_execution_end") {
197
+ if (progress.currentTool) {
198
+ progress.recentTools.unshift({
199
+ tool: progress.currentTool,
200
+ args: progress.currentToolArgs || "",
201
+ endMs: now,
202
+ });
203
+ if (progress.recentTools.length > 5) {
204
+ progress.recentTools.pop();
205
+ }
206
+ }
207
+ progress.currentTool = undefined;
208
+ progress.currentToolArgs = undefined;
209
+ scheduleUpdate();
210
+ }
211
+
212
+ if (evt.type === "message_end" && evt.message) {
213
+ result.messages.push(evt.message);
214
+ if (evt.message.role === "assistant") {
215
+ result.usage.turns++;
216
+ const u = evt.message.usage;
217
+ if (u) {
218
+ result.usage.input += u.input || 0;
219
+ result.usage.output += u.output || 0;
220
+ result.usage.cacheRead += u.cacheRead || 0;
221
+ result.usage.cacheWrite += u.cacheWrite || 0;
222
+ result.usage.cost += u.cost?.total || 0;
223
+ progress.tokens = result.usage.input + result.usage.output;
224
+ }
225
+ if (!result.model && evt.message.model) result.model = evt.message.model;
226
+ if (evt.message.errorMessage) result.error = evt.message.errorMessage;
227
+
228
+ const text = extractTextFromContent(evt.message.content);
229
+ if (text) {
230
+ const lines = text
231
+ .split("\n")
232
+ .filter((l) => l.trim())
233
+ .slice(-10);
234
+ // Append to existing recentOutput (keep last 50 total) - mutate in place for efficiency
235
+ progress.recentOutput.push(...lines);
236
+ if (progress.recentOutput.length > 50) {
237
+ progress.recentOutput.splice(0, progress.recentOutput.length - 50);
238
+ }
239
+ }
240
+ }
241
+ scheduleUpdate();
242
+ }
243
+ if (evt.type === "tool_result_end" && evt.message) {
244
+ result.messages.push(evt.message);
245
+ // Also capture tool result text in recentOutput for streaming display
246
+ const toolText = extractTextFromContent(evt.message.content);
247
+ if (toolText) {
248
+ const toolLines = toolText
249
+ .split("\n")
250
+ .filter((l) => l.trim())
251
+ .slice(-10);
252
+ // Append to existing recentOutput (keep last 50 total) - mutate in place for efficiency
253
+ progress.recentOutput.push(...toolLines);
254
+ if (progress.recentOutput.length > 50) {
255
+ progress.recentOutput.splice(0, progress.recentOutput.length - 50);
256
+ }
257
+ }
258
+ scheduleUpdate();
259
+ }
260
+ } catch {}
261
+ };
262
+
263
+ let stderrBuf = "";
264
+
265
+ proc.stdout.on("data", (d) => {
266
+ buf += d.toString();
267
+ const lines = buf.split("\n");
268
+ buf = lines.pop() || "";
269
+ lines.forEach(processLine);
270
+
271
+ // Also schedule an update on data received (handles streaming output)
272
+ scheduleUpdate();
273
+ });
274
+ proc.stderr.on("data", (d) => {
275
+ stderrBuf += d.toString();
276
+ });
277
+ proc.on("close", (code) => {
278
+ processClosed = true;
279
+ if (pendingTimer) {
280
+ clearTimeout(pendingTimer);
281
+ pendingTimer = null;
282
+ }
283
+ if (buf.trim()) processLine(buf);
284
+ if (code !== 0 && stderrBuf.trim() && !result.error) {
285
+ result.error = stderrBuf.trim();
286
+ }
287
+ resolve(code ?? 0);
288
+ });
289
+ proc.on("error", () => resolve(1));
290
+
291
+ if (signal) {
292
+ const kill = () => {
293
+ proc.kill("SIGTERM");
294
+ setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
295
+ };
296
+ if (signal.aborted) kill();
297
+ else signal.addEventListener("abort", kill, { once: true });
298
+ }
299
+ });
300
+
301
+ if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
302
+ result.exitCode = exitCode;
303
+
304
+ if (exitCode === 0 && !result.error) {
305
+ const errInfo = detectSubagentError(result.messages);
306
+ if (errInfo.hasError) {
307
+ result.exitCode = errInfo.exitCode ?? 1;
308
+ result.error = errInfo.details
309
+ ? `${errInfo.errorType} failed (exit ${errInfo.exitCode}): ${errInfo.details}`
310
+ : `${errInfo.errorType} failed with exit code ${errInfo.exitCode}`;
311
+ }
312
+ }
313
+
314
+ progress.status = result.exitCode === 0 ? "completed" : "failed";
315
+ progress.durationMs = Date.now() - startTime;
316
+ if (result.error) {
317
+ progress.error = result.error;
318
+ if (progress.currentTool) {
319
+ progress.failedTool = progress.currentTool;
320
+ }
321
+ }
322
+
323
+ result.progress = progress;
324
+ result.progressSummary = {
325
+ toolCount: progress.toolCount,
326
+ tokens: progress.tokens,
327
+ durationMs: progress.durationMs,
328
+ };
329
+
330
+ if (artifactPathsResult && artifactConfig?.enabled !== false) {
331
+ result.artifactPaths = artifactPathsResult;
332
+ const fullOutput = getFinalOutput(result.messages);
333
+
334
+ if (artifactConfig?.includeOutput !== false) {
335
+ writeArtifact(artifactPathsResult.outputPath, fullOutput);
336
+ }
337
+ if (artifactConfig?.includeJsonl !== false) {
338
+ for (const line of jsonlLines) {
339
+ appendJsonl(artifactPathsResult.jsonlPath, line);
340
+ }
341
+ }
342
+ if (artifactConfig?.includeMetadata !== false) {
343
+ writeMetadata(artifactPathsResult.metadataPath, {
344
+ runId,
345
+ agent: agentName,
346
+ task,
347
+ exitCode: result.exitCode,
348
+ usage: result.usage,
349
+ model: result.model,
350
+ durationMs: progress.durationMs,
351
+ toolCount: progress.toolCount,
352
+ error: result.error,
353
+ timestamp: Date.now(),
354
+ });
355
+ }
356
+
357
+ if (maxOutput) {
358
+ const config = { ...DEFAULT_MAX_OUTPUT, ...maxOutput };
359
+ const truncationResult = truncateOutput(fullOutput, config, artifactPathsResult.outputPath);
360
+ if (truncationResult.truncated) {
361
+ result.truncation = truncationResult;
362
+ }
363
+ }
364
+ } else if (maxOutput) {
365
+ const config = { ...DEFAULT_MAX_OUTPUT, ...maxOutput };
366
+ const fullOutput = getFinalOutput(result.messages);
367
+ const truncationResult = truncateOutput(fullOutput, config);
368
+ if (truncationResult.truncated) {
369
+ result.truncation = truncationResult;
370
+ }
371
+ }
372
+
373
+ if (shareEnabled && options.sessionDir) {
374
+ const sessionFile = findLatestSessionFile(options.sessionDir);
375
+ if (sessionFile) {
376
+ result.sessionFile = sessionFile;
377
+ // HTML export disabled - module resolution issues with global pi installation
378
+ // Users can still access the session file directly
379
+ }
380
+ }
381
+
382
+ return result;
383
+ }
package/formatters.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Formatting utilities for display output
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import type { Usage, SingleResult } from "./types.js";
8
+ import type { ChainStep, SequentialStep } from "./settings.js";
9
+ import { isParallelStep } from "./settings.js";
10
+
11
+ /**
12
+ * Format token count with k suffix for large numbers
13
+ */
14
+ export function formatTokens(n: number): string {
15
+ return n < 1000 ? String(n) : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`;
16
+ }
17
+
18
+ /**
19
+ * Format usage statistics into a compact string
20
+ */
21
+ export function formatUsage(u: Usage, model?: string): string {
22
+ const parts: string[] = [];
23
+ if (u.turns) parts.push(`${u.turns} turn${u.turns > 1 ? "s" : ""}`);
24
+ if (u.input) parts.push(`in:${formatTokens(u.input)}`);
25
+ if (u.output) parts.push(`out:${formatTokens(u.output)}`);
26
+ if (u.cacheRead) parts.push(`R${formatTokens(u.cacheRead)}`);
27
+ if (u.cacheWrite) parts.push(`W${formatTokens(u.cacheWrite)}`);
28
+ if (u.cost) parts.push(`$${u.cost.toFixed(4)}`);
29
+ if (model) parts.push(model);
30
+ return parts.join(" ");
31
+ }
32
+
33
+ /**
34
+ * Format duration in human-readable form
35
+ */
36
+ export function formatDuration(ms: number): string {
37
+ if (ms < 1000) return `${ms}ms`;
38
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
39
+ return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
40
+ }
41
+
42
+ /**
43
+ * Build a summary string for a completed/failed chain
44
+ */
45
+ export function buildChainSummary(
46
+ steps: ChainStep[],
47
+ results: SingleResult[],
48
+ chainDir: string,
49
+ status: "completed" | "failed",
50
+ failedStep?: { index: number; error: string },
51
+ ): string {
52
+ // Build step names for display
53
+ const stepNames = steps
54
+ .map((s) => (isParallelStep(s) ? `parallel[${s.parallel.length}]` : (s as SequentialStep).agent))
55
+ .join(" → ");
56
+
57
+ // Calculate total duration from results
58
+ const totalDuration = results.reduce((sum, r) => sum + (r.progress?.durationMs || 0), 0);
59
+ const durationStr = formatDuration(totalDuration);
60
+
61
+ // Check for progress.md
62
+ const progressPath = path.join(chainDir, "progress.md");
63
+ const hasProgress = fs.existsSync(progressPath);
64
+
65
+ if (status === "completed") {
66
+ const stepWord = results.length === 1 ? "step" : "steps";
67
+ return `✅ Chain completed: ${stepNames} (${results.length} ${stepWord}, ${durationStr})
68
+
69
+ 📋 Progress: ${hasProgress ? progressPath : "(none)"}
70
+ 📁 Artifacts: ${chainDir}`;
71
+ } else {
72
+ const stepInfo = failedStep ? ` at step ${failedStep.index + 1}` : "";
73
+ const errorInfo = failedStep?.error ? `: ${failedStep.error}` : "";
74
+ return `❌ Chain failed${stepInfo}${errorInfo}
75
+
76
+ 📋 Progress: ${hasProgress ? progressPath : "(none)"}
77
+ 📁 Artifacts: ${chainDir}`;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Format a tool call for display
83
+ */
84
+ export function formatToolCall(name: string, args: Record<string, unknown>): string {
85
+ switch (name) {
86
+ case "bash":
87
+ return `$ ${((args.command as string) || "").slice(0, 60)}${(args.command as string)?.length > 60 ? "..." : ""}`;
88
+ case "read":
89
+ return `read ${shortenPath((args.path || args.file_path || "") as string)}`;
90
+ case "write":
91
+ return `write ${shortenPath((args.path || args.file_path || "") as string)}`;
92
+ case "edit":
93
+ return `edit ${shortenPath((args.path || args.file_path || "") as string)}`;
94
+ default: {
95
+ const s = JSON.stringify(args);
96
+ return `${name} ${s.slice(0, 40)}${s.length > 40 ? "..." : ""}`;
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Shorten a path by replacing home directory with ~
103
+ */
104
+ export function shortenPath(p: string): string {
105
+ const home = process.env.HOME;
106
+ // Only shorten if HOME is defined and non-empty, and path starts with it
107
+ if (home && p.startsWith(home)) {
108
+ return `~${p.slice(home.length)}`;
109
+ }
110
+ return p;
111
+ }