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/types.ts CHANGED
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Type definitions for the subagent extension
3
+ */
4
+
5
+ import type { Message } from "@mariozechner/pi-ai";
6
+
7
+ // ============================================================================
8
+ // Basic Types
9
+ // ============================================================================
10
+
1
11
  export interface MaxOutputConfig {
2
12
  bytes?: number;
3
13
  lines?: number;
@@ -11,6 +21,25 @@ export interface TruncationResult {
11
21
  artifactPath?: string;
12
22
  }
13
23
 
24
+ export interface Usage {
25
+ input: number;
26
+ output: number;
27
+ cacheRead: number;
28
+ cacheWrite: number;
29
+ cost: number;
30
+ turns: number;
31
+ }
32
+
33
+ export interface TokenUsage {
34
+ input: number;
35
+ output: number;
36
+ total: number;
37
+ }
38
+
39
+ // ============================================================================
40
+ // Progress Tracking
41
+ // ============================================================================
42
+
14
43
  export interface AgentProgress {
15
44
  index: number;
16
45
  agent: string;
@@ -33,6 +62,52 @@ export interface ProgressSummary {
33
62
  durationMs: number;
34
63
  }
35
64
 
65
+ // ============================================================================
66
+ // Results
67
+ // ============================================================================
68
+
69
+ export interface SingleResult {
70
+ agent: string;
71
+ task: string;
72
+ exitCode: number;
73
+ messages: Message[];
74
+ usage: Usage;
75
+ model?: string;
76
+ error?: string;
77
+ sessionFile?: string;
78
+ progress?: AgentProgress;
79
+ progressSummary?: ProgressSummary;
80
+ artifactPaths?: ArtifactPaths;
81
+ truncation?: TruncationResult;
82
+ }
83
+
84
+ export interface Details {
85
+ mode: "single" | "parallel" | "chain";
86
+ results: SingleResult[];
87
+ asyncId?: string;
88
+ asyncDir?: string;
89
+ progress?: AgentProgress[];
90
+ progressSummary?: ProgressSummary;
91
+ artifacts?: {
92
+ dir: string;
93
+ files: ArtifactPaths[];
94
+ };
95
+ truncation?: {
96
+ truncated: boolean;
97
+ originalBytes?: number;
98
+ originalLines?: number;
99
+ artifactPath?: string;
100
+ };
101
+ // Chain metadata for observability
102
+ chainAgents?: string[]; // Agent names in order, e.g., ["scout", "planner"]
103
+ totalSteps?: number; // Total steps in chain
104
+ currentStepIndex?: number; // 0-indexed current step (for running chains)
105
+ }
106
+
107
+ // ============================================================================
108
+ // Artifacts
109
+ // ============================================================================
110
+
36
111
  export interface ArtifactPaths {
37
112
  inputPath: string;
38
113
  outputPath: string;
@@ -49,6 +124,85 @@ export interface ArtifactConfig {
49
124
  cleanupDays: number;
50
125
  }
51
126
 
127
+ // ============================================================================
128
+ // Async Execution
129
+ // ============================================================================
130
+
131
+ export interface AsyncStatus {
132
+ runId: string;
133
+ mode: "single" | "chain";
134
+ state: "queued" | "running" | "complete" | "failed";
135
+ startedAt: number;
136
+ endedAt?: number;
137
+ lastUpdate?: number;
138
+ currentStep?: number;
139
+ steps?: Array<{ agent: string; status: string; durationMs?: number; tokens?: TokenUsage }>;
140
+ sessionDir?: string;
141
+ outputFile?: string;
142
+ totalTokens?: TokenUsage;
143
+ sessionFile?: string;
144
+ }
145
+
146
+ export interface AsyncJobState {
147
+ asyncId: string;
148
+ asyncDir: string;
149
+ status: "queued" | "running" | "complete" | "failed";
150
+ mode?: "single" | "chain";
151
+ agents?: string[];
152
+ currentStep?: number;
153
+ stepsTotal?: number;
154
+ startedAt?: number;
155
+ updatedAt?: number;
156
+ sessionDir?: string;
157
+ outputFile?: string;
158
+ totalTokens?: TokenUsage;
159
+ sessionFile?: string;
160
+ }
161
+
162
+ // ============================================================================
163
+ // Display
164
+ // ============================================================================
165
+
166
+ export type DisplayItem =
167
+ | { type: "text"; text: string }
168
+ | { type: "tool"; name: string; args: Record<string, unknown> };
169
+
170
+ // ============================================================================
171
+ // Error Handling
172
+ // ============================================================================
173
+
174
+ export interface ErrorInfo {
175
+ hasError: boolean;
176
+ exitCode?: number;
177
+ errorType?: string;
178
+ details?: string;
179
+ }
180
+
181
+ // ============================================================================
182
+ // Execution Options
183
+ // ============================================================================
184
+
185
+ export interface RunSyncOptions {
186
+ cwd?: string;
187
+ signal?: AbortSignal;
188
+ onUpdate?: (r: import("@mariozechner/pi-agent-core").AgentToolResult<Details>) => void;
189
+ maxOutput?: MaxOutputConfig;
190
+ artifactsDir?: string;
191
+ artifactConfig?: ArtifactConfig;
192
+ runId: string;
193
+ index?: number;
194
+ sessionDir?: string;
195
+ share?: boolean;
196
+ }
197
+
198
+ export interface ExtensionConfig {
199
+ asyncByDefault?: boolean;
200
+ }
201
+
202
+ // ============================================================================
203
+ // Constants
204
+ // ============================================================================
205
+
52
206
  export const DEFAULT_MAX_OUTPUT: Required<MaxOutputConfig> = {
53
207
  bytes: 200 * 1024,
54
208
  lines: 5000,
@@ -63,6 +217,18 @@ export const DEFAULT_ARTIFACT_CONFIG: ArtifactConfig = {
63
217
  cleanupDays: 7,
64
218
  };
65
219
 
220
+ export const MAX_PARALLEL = 8;
221
+ export const MAX_CONCURRENCY = 4;
222
+ export const RESULTS_DIR = "/tmp/pi-async-subagent-results";
223
+ export const ASYNC_DIR = "/tmp/pi-async-subagent-runs";
224
+ export const WIDGET_KEY = "subagent-async";
225
+ export const POLL_INTERVAL_MS = 250;
226
+ export const MAX_WIDGET_JOBS = 4;
227
+
228
+ // ============================================================================
229
+ // Utility Functions
230
+ // ============================================================================
231
+
66
232
  export function formatBytes(bytes: number): string {
67
233
  if (bytes < 1024) return `${bytes}B`;
68
234
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
package/utils.ts ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * General utility functions for the subagent extension
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import type { Message } from "@mariozechner/pi-ai";
9
+ import type { AsyncStatus, DisplayItem, ErrorInfo } from "./types.js";
10
+
11
+ // ============================================================================
12
+ // File System Utilities
13
+ // ============================================================================
14
+
15
+ // Cache for status file reads - avoid re-reading unchanged files
16
+ const statusCache = new Map<string, { mtime: number; status: AsyncStatus }>();
17
+
18
+ /**
19
+ * Read async job status from disk (with mtime-based caching)
20
+ */
21
+ export function readStatus(asyncDir: string): AsyncStatus | null {
22
+ const statusPath = path.join(asyncDir, "status.json");
23
+ try {
24
+ const stat = fs.statSync(statusPath);
25
+ const cached = statusCache.get(statusPath);
26
+ if (cached && cached.mtime === stat.mtimeMs) {
27
+ return cached.status;
28
+ }
29
+ const content = fs.readFileSync(statusPath, "utf-8");
30
+ const status = JSON.parse(content) as AsyncStatus;
31
+ statusCache.set(statusPath, { mtime: stat.mtimeMs, status });
32
+ // Limit cache size to prevent memory leaks
33
+ if (statusCache.size > 50) {
34
+ const firstKey = statusCache.keys().next().value;
35
+ if (firstKey) statusCache.delete(firstKey);
36
+ }
37
+ return status;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ // Cache for output tail reads - avoid re-reading unchanged files
44
+ const outputTailCache = new Map<string, { mtime: number; size: number; lines: string[] }>();
45
+
46
+ /**
47
+ * Get the last N lines from an output file (with mtime/size-based caching)
48
+ */
49
+ export function getOutputTail(outputFile: string | undefined, maxLines: number = 3): string[] {
50
+ if (!outputFile) return [];
51
+ let fd: number | null = null;
52
+ try {
53
+ const stat = fs.statSync(outputFile);
54
+ if (stat.size === 0) return [];
55
+
56
+ // Check cache using both mtime and size (size changes more frequently during writes)
57
+ const cached = outputTailCache.get(outputFile);
58
+ if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) {
59
+ return cached.lines;
60
+ }
61
+
62
+ const tailBytes = 4096;
63
+ const start = Math.max(0, stat.size - tailBytes);
64
+ fd = fs.openSync(outputFile, "r");
65
+ const buffer = Buffer.alloc(Math.min(tailBytes, stat.size));
66
+ fs.readSync(fd, buffer, 0, buffer.length, start);
67
+ const content = buffer.toString("utf-8");
68
+ const allLines = content.split("\n").filter((l) => l.trim());
69
+ const lines = allLines.slice(-maxLines).map((l) => l.slice(0, 120) + (l.length > 120 ? "..." : ""));
70
+
71
+ // Cache the result
72
+ outputTailCache.set(outputFile, { mtime: stat.mtimeMs, size: stat.size, lines });
73
+ // Limit cache size
74
+ if (outputTailCache.size > 20) {
75
+ const firstKey = outputTailCache.keys().next().value;
76
+ if (firstKey) outputTailCache.delete(firstKey);
77
+ }
78
+
79
+ return lines;
80
+ } catch {
81
+ return [];
82
+ } finally {
83
+ if (fd !== null) {
84
+ try {
85
+ fs.closeSync(fd);
86
+ } catch {}
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Get human-readable last activity time for a file
93
+ */
94
+ export function getLastActivity(outputFile: string | undefined): string {
95
+ if (!outputFile) return "";
96
+ try {
97
+ // Single stat call - throws if file doesn't exist
98
+ const stat = fs.statSync(outputFile);
99
+ const ago = Date.now() - stat.mtimeMs;
100
+ if (ago < 1000) return "active now";
101
+ if (ago < 60000) return `active ${Math.floor(ago / 1000)}s ago`;
102
+ return `active ${Math.floor(ago / 60000)}m ago`;
103
+ } catch {
104
+ return "";
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Find a file/directory by prefix in a directory
110
+ */
111
+ export function findByPrefix(dir: string, prefix: string, suffix?: string): string | null {
112
+ if (!fs.existsSync(dir)) return null;
113
+ const entries = fs.readdirSync(dir).filter((entry) => entry.startsWith(prefix));
114
+ if (suffix) {
115
+ const withSuffix = entries.filter((entry) => entry.endsWith(suffix));
116
+ if (withSuffix.length > 0) return path.join(dir, withSuffix.sort()[0]);
117
+ }
118
+ if (entries.length === 0) return null;
119
+ return path.join(dir, entries.sort()[0]);
120
+ }
121
+
122
+ /**
123
+ * Find the latest session file in a directory
124
+ */
125
+ export function findLatestSessionFile(sessionDir: string): string | null {
126
+ if (!fs.existsSync(sessionDir)) return null;
127
+ const files = fs.readdirSync(sessionDir)
128
+ .filter((f) => f.endsWith(".jsonl"))
129
+ .map((f) => {
130
+ const filePath = path.join(sessionDir, f);
131
+ return {
132
+ name: f,
133
+ path: filePath,
134
+ mtime: fs.statSync(filePath).mtimeMs,
135
+ };
136
+ })
137
+ .sort((a, b) => b.mtime - a.mtime);
138
+ return files.length > 0 ? files[0].path : null;
139
+ }
140
+
141
+ /**
142
+ * Write a prompt to a temporary file
143
+ */
144
+ export function writePrompt(agent: string, prompt: string): { dir: string; path: string } {
145
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
146
+ const p = path.join(dir, `${agent.replace(/[^\w.-]/g, "_")}.md`);
147
+ fs.writeFileSync(p, prompt, { mode: 0o600 });
148
+ return { dir, path: p };
149
+ }
150
+
151
+ // ============================================================================
152
+ // Message Parsing Utilities
153
+ // ============================================================================
154
+
155
+ /**
156
+ * Get the final text output from a list of messages
157
+ */
158
+ export function getFinalOutput(messages: Message[]): string {
159
+ for (let i = messages.length - 1; i >= 0; i--) {
160
+ const msg = messages[i];
161
+ if (msg.role === "assistant") {
162
+ for (const part of msg.content) {
163
+ if (part.type === "text") return part.text;
164
+ }
165
+ }
166
+ }
167
+ return "";
168
+ }
169
+
170
+ /**
171
+ * Extract display items (text and tool calls) from messages
172
+ */
173
+ export function getDisplayItems(messages: Message[]): DisplayItem[] {
174
+ const items: DisplayItem[] = [];
175
+ for (const msg of messages) {
176
+ if (msg.role === "assistant") {
177
+ for (const part of msg.content) {
178
+ if (part.type === "text") items.push({ type: "text", text: part.text });
179
+ else if (part.type === "toolCall") items.push({ type: "tool", name: part.name, args: part.arguments });
180
+ }
181
+ }
182
+ }
183
+ return items;
184
+ }
185
+
186
+ /**
187
+ * Detect errors in subagent execution from messages
188
+ */
189
+ export function detectSubagentError(messages: Message[]): ErrorInfo {
190
+ for (const msg of messages) {
191
+ if (msg.role === "toolResult" && (msg as any).isError) {
192
+ const text = msg.content.find((c) => c.type === "text");
193
+ const details = text && "text" in text ? text.text : undefined;
194
+ const exitMatch = details?.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i);
195
+ return {
196
+ hasError: true,
197
+ exitCode: exitMatch ? parseInt(exitMatch[1], 10) : 1,
198
+ errorType: (msg as any).toolName || "tool",
199
+ details: details?.slice(0, 200),
200
+ };
201
+ }
202
+ }
203
+
204
+ for (const msg of messages) {
205
+ if (msg.role !== "toolResult") continue;
206
+ const toolName = (msg as any).toolName;
207
+ if (toolName !== "bash") continue;
208
+
209
+ const text = msg.content.find((c) => c.type === "text");
210
+ if (!text || !("text" in text)) continue;
211
+ const output = text.text;
212
+
213
+ const exitMatch = output.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i);
214
+ if (exitMatch) {
215
+ const code = parseInt(exitMatch[1], 10);
216
+ if (code !== 0) {
217
+ return { hasError: true, exitCode: code, errorType: "bash", details: output.slice(0, 200) };
218
+ }
219
+ }
220
+
221
+ const errorPatterns = [
222
+ /command not found/i,
223
+ /permission denied/i,
224
+ /no such file or directory/i,
225
+ /segmentation fault/i,
226
+ /killed|terminated/i,
227
+ /out of memory/i,
228
+ /connection refused/i,
229
+ /timeout/i,
230
+ ];
231
+ for (const pattern of errorPatterns) {
232
+ if (pattern.test(output)) {
233
+ return { hasError: true, exitCode: 1, errorType: "bash", details: output.slice(0, 200) };
234
+ }
235
+ }
236
+ }
237
+
238
+ return { hasError: false };
239
+ }
240
+
241
+ /**
242
+ * Extract a preview of tool arguments for display
243
+ */
244
+ export function extractToolArgsPreview(args: Record<string, unknown>): string {
245
+ // Handle MCP tool calls - show server/tool info
246
+ if (args.tool && typeof args.tool === "string") {
247
+ const server = args.server && typeof args.server === "string" ? `${args.server}/` : "";
248
+ const toolArgs = args.args && typeof args.args === "string" ? ` ${args.args.slice(0, 40)}` : "";
249
+ return `${server}${args.tool}${toolArgs}`;
250
+ }
251
+
252
+ const previewKeys = ["command", "path", "file_path", "pattern", "query", "url", "task", "describe", "search"];
253
+ for (const key of previewKeys) {
254
+ if (args[key] && typeof args[key] === "string") {
255
+ const value = args[key] as string;
256
+ return value.length > 60 ? `${value.slice(0, 57)}...` : value;
257
+ }
258
+ }
259
+
260
+ // Fallback: show first string value found
261
+ for (const [key, value] of Object.entries(args)) {
262
+ if (typeof value === "string" && value.length > 0) {
263
+ const preview = value.length > 50 ? `${value.slice(0, 47)}...` : value;
264
+ return `${key}=${preview}`;
265
+ }
266
+ }
267
+ return "";
268
+ }
269
+
270
+ /**
271
+ * Extract text content from various message content formats
272
+ */
273
+ export function extractTextFromContent(content: unknown): string {
274
+ if (!content) return "";
275
+ // Handle string content directly
276
+ if (typeof content === "string") return content;
277
+ // Handle array content
278
+ if (!Array.isArray(content)) return "";
279
+ const texts: string[] = [];
280
+ for (const part of content) {
281
+ if (part && typeof part === "object") {
282
+ // Handle { type: "text", text: "..." }
283
+ if ("type" in part && part.type === "text" && "text" in part) {
284
+ texts.push(String(part.text));
285
+ }
286
+ // Handle { type: "tool_result", content: "..." }
287
+ else if ("type" in part && part.type === "tool_result" && "content" in part) {
288
+ const inner = extractTextFromContent(part.content);
289
+ if (inner) texts.push(inner);
290
+ }
291
+ // Handle { text: "..." } without type
292
+ else if ("text" in part) {
293
+ texts.push(String(part.text));
294
+ }
295
+ }
296
+ }
297
+ return texts.join("\n");
298
+ }
299
+
300
+ // ============================================================================
301
+ // Concurrency Utilities
302
+ // ============================================================================
303
+
304
+ /**
305
+ * Map over items with limited concurrency
306
+ */
307
+ export async function mapConcurrent<T, R>(
308
+ items: T[],
309
+ limit: number,
310
+ fn: (item: T, i: number) => Promise<R>,
311
+ ): Promise<R[]> {
312
+ const results: R[] = new Array(items.length);
313
+ let next = 0;
314
+
315
+ async function worker(): Promise<void> {
316
+ while (next < items.length) {
317
+ const i = next++;
318
+ results[i] = await fn(items[i], i);
319
+ }
320
+ }
321
+
322
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
323
+ await Promise.all(workers);
324
+ return results;
325
+ }