pi-subagents 0.3.0 → 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/execution.ts ADDED
@@ -0,0 +1,352 @@
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
+ const processLine = (line: string) => {
136
+ if (!line.trim()) return;
137
+ jsonlLines.push(line);
138
+ try {
139
+ const evt = JSON.parse(line) as { type?: string; message?: Message; toolName?: string; args?: unknown };
140
+ const now = Date.now();
141
+ progress.durationMs = now - startTime;
142
+
143
+ if (evt.type === "tool_execution_start") {
144
+ progress.toolCount++;
145
+ progress.currentTool = evt.toolName;
146
+ progress.currentToolArgs = extractToolArgsPreview((evt.args || {}) as Record<string, unknown>);
147
+ if (onUpdate)
148
+ onUpdate({
149
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
150
+ details: { mode: "single", results: [result], progress: [progress] },
151
+ });
152
+ }
153
+
154
+ if (evt.type === "tool_execution_end") {
155
+ if (progress.currentTool) {
156
+ progress.recentTools.unshift({
157
+ tool: progress.currentTool,
158
+ args: progress.currentToolArgs || "",
159
+ endMs: now,
160
+ });
161
+ if (progress.recentTools.length > 5) {
162
+ progress.recentTools.pop();
163
+ }
164
+ }
165
+ progress.currentTool = undefined;
166
+ progress.currentToolArgs = undefined;
167
+ if (onUpdate)
168
+ onUpdate({
169
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
170
+ details: { mode: "single", results: [result], progress: [progress] },
171
+ });
172
+ }
173
+
174
+ if (evt.type === "message_end" && evt.message) {
175
+ result.messages.push(evt.message);
176
+ if (evt.message.role === "assistant") {
177
+ result.usage.turns++;
178
+ const u = evt.message.usage;
179
+ if (u) {
180
+ result.usage.input += u.input || 0;
181
+ result.usage.output += u.output || 0;
182
+ result.usage.cacheRead += u.cacheRead || 0;
183
+ result.usage.cacheWrite += u.cacheWrite || 0;
184
+ result.usage.cost += u.cost?.total || 0;
185
+ progress.tokens = result.usage.input + result.usage.output;
186
+ }
187
+ if (!result.model && evt.message.model) result.model = evt.message.model;
188
+ if (evt.message.errorMessage) result.error = evt.message.errorMessage;
189
+
190
+ const text = extractTextFromContent(evt.message.content);
191
+ if (text) {
192
+ const lines = text
193
+ .split("\n")
194
+ .filter((l) => l.trim())
195
+ .slice(-10);
196
+ // Append to existing recentOutput (keep last 50 total)
197
+ progress.recentOutput = [...progress.recentOutput, ...lines].slice(-50);
198
+ }
199
+ }
200
+ if (onUpdate)
201
+ onUpdate({
202
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
203
+ details: { mode: "single", results: [result], progress: [progress] },
204
+ });
205
+ }
206
+ if (evt.type === "tool_result_end" && evt.message) {
207
+ result.messages.push(evt.message);
208
+ // Also capture tool result text in recentOutput for streaming display
209
+ const toolText = extractTextFromContent(evt.message.content);
210
+ if (toolText) {
211
+ const toolLines = toolText
212
+ .split("\n")
213
+ .filter((l) => l.trim())
214
+ .slice(-10);
215
+ // Append to existing recentOutput (keep last 50 total)
216
+ progress.recentOutput = [...progress.recentOutput, ...toolLines].slice(-50);
217
+ }
218
+ if (onUpdate)
219
+ onUpdate({
220
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
221
+ details: { mode: "single", results: [result], progress: [progress] },
222
+ });
223
+ }
224
+ } catch {}
225
+ };
226
+
227
+ let stderrBuf = "";
228
+ let lastUpdateTime = 0;
229
+ const UPDATE_THROTTLE_MS = 75;
230
+
231
+ proc.stdout.on("data", (d) => {
232
+ buf += d.toString();
233
+ const lines = buf.split("\n");
234
+ buf = lines.pop() || "";
235
+ lines.forEach(processLine);
236
+
237
+ // Throttled periodic update for smoother progress display
238
+ const now = Date.now();
239
+ if (onUpdate && now - lastUpdateTime > UPDATE_THROTTLE_MS) {
240
+ lastUpdateTime = now;
241
+ progress.durationMs = now - startTime;
242
+ onUpdate({
243
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
244
+ details: { mode: "single", results: [result], progress: [progress] },
245
+ });
246
+ }
247
+ });
248
+ proc.stderr.on("data", (d) => {
249
+ stderrBuf += d.toString();
250
+ });
251
+ proc.on("close", (code) => {
252
+ if (buf.trim()) processLine(buf);
253
+ if (code !== 0 && stderrBuf.trim() && !result.error) {
254
+ result.error = stderrBuf.trim();
255
+ }
256
+ resolve(code ?? 0);
257
+ });
258
+ proc.on("error", () => resolve(1));
259
+
260
+ if (signal) {
261
+ const kill = () => {
262
+ proc.kill("SIGTERM");
263
+ setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
264
+ };
265
+ if (signal.aborted) kill();
266
+ else signal.addEventListener("abort", kill, { once: true });
267
+ }
268
+ });
269
+
270
+ if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
271
+ result.exitCode = exitCode;
272
+
273
+ if (exitCode === 0 && !result.error) {
274
+ const errInfo = detectSubagentError(result.messages);
275
+ if (errInfo.hasError) {
276
+ result.exitCode = errInfo.exitCode ?? 1;
277
+ result.error = errInfo.details
278
+ ? `${errInfo.errorType} failed (exit ${errInfo.exitCode}): ${errInfo.details}`
279
+ : `${errInfo.errorType} failed with exit code ${errInfo.exitCode}`;
280
+ }
281
+ }
282
+
283
+ progress.status = result.exitCode === 0 ? "completed" : "failed";
284
+ progress.durationMs = Date.now() - startTime;
285
+ if (result.error) {
286
+ progress.error = result.error;
287
+ if (progress.currentTool) {
288
+ progress.failedTool = progress.currentTool;
289
+ }
290
+ }
291
+
292
+ result.progress = progress;
293
+ result.progressSummary = {
294
+ toolCount: progress.toolCount,
295
+ tokens: progress.tokens,
296
+ durationMs: progress.durationMs,
297
+ };
298
+
299
+ if (artifactPathsResult && artifactConfig?.enabled !== false) {
300
+ result.artifactPaths = artifactPathsResult;
301
+ const fullOutput = getFinalOutput(result.messages);
302
+
303
+ if (artifactConfig?.includeOutput !== false) {
304
+ writeArtifact(artifactPathsResult.outputPath, fullOutput);
305
+ }
306
+ if (artifactConfig?.includeJsonl !== false) {
307
+ for (const line of jsonlLines) {
308
+ appendJsonl(artifactPathsResult.jsonlPath, line);
309
+ }
310
+ }
311
+ if (artifactConfig?.includeMetadata !== false) {
312
+ writeMetadata(artifactPathsResult.metadataPath, {
313
+ runId,
314
+ agent: agentName,
315
+ task,
316
+ exitCode: result.exitCode,
317
+ usage: result.usage,
318
+ model: result.model,
319
+ durationMs: progress.durationMs,
320
+ toolCount: progress.toolCount,
321
+ error: result.error,
322
+ timestamp: Date.now(),
323
+ });
324
+ }
325
+
326
+ if (maxOutput) {
327
+ const config = { ...DEFAULT_MAX_OUTPUT, ...maxOutput };
328
+ const truncationResult = truncateOutput(fullOutput, config, artifactPathsResult.outputPath);
329
+ if (truncationResult.truncated) {
330
+ result.truncation = truncationResult;
331
+ }
332
+ }
333
+ } else if (maxOutput) {
334
+ const config = { ...DEFAULT_MAX_OUTPUT, ...maxOutput };
335
+ const fullOutput = getFinalOutput(result.messages);
336
+ const truncationResult = truncateOutput(fullOutput, config);
337
+ if (truncationResult.truncated) {
338
+ result.truncation = truncationResult;
339
+ }
340
+ }
341
+
342
+ if (shareEnabled && options.sessionDir) {
343
+ const sessionFile = findLatestSessionFile(options.sessionDir);
344
+ if (sessionFile) {
345
+ result.sessionFile = sessionFile;
346
+ // HTML export disabled - module resolution issues with global pi installation
347
+ // Users can still access the session file directly
348
+ }
349
+ }
350
+
351
+ return result;
352
+ }
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
+ }