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/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 = 1000;
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,287 @@
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
+ /**
16
+ * Read async job status from disk
17
+ */
18
+ export function readStatus(asyncDir: string): AsyncStatus | null {
19
+ const statusPath = path.join(asyncDir, "status.json");
20
+ if (!fs.existsSync(statusPath)) return null;
21
+ try {
22
+ const content = fs.readFileSync(statusPath, "utf-8");
23
+ return JSON.parse(content) as AsyncStatus;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Get the last N lines from an output file
31
+ */
32
+ export function getOutputTail(outputFile: string | undefined, maxLines: number = 3): string[] {
33
+ if (!outputFile || !fs.existsSync(outputFile)) return [];
34
+ let fd: number | null = null;
35
+ try {
36
+ const stat = fs.statSync(outputFile);
37
+ if (stat.size === 0) return [];
38
+ const tailBytes = 4096;
39
+ const start = Math.max(0, stat.size - tailBytes);
40
+ fd = fs.openSync(outputFile, "r");
41
+ const buffer = Buffer.alloc(Math.min(tailBytes, stat.size));
42
+ fs.readSync(fd, buffer, 0, buffer.length, start);
43
+ const content = buffer.toString("utf-8");
44
+ const lines = content.split("\n").filter((l) => l.trim());
45
+ return lines.slice(-maxLines).map((l) => l.slice(0, 120) + (l.length > 120 ? "..." : ""));
46
+ } catch {
47
+ return [];
48
+ } finally {
49
+ if (fd !== null) {
50
+ try {
51
+ fs.closeSync(fd);
52
+ } catch {}
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Get human-readable last activity time for a file
59
+ */
60
+ export function getLastActivity(outputFile: string | undefined): string {
61
+ if (!outputFile || !fs.existsSync(outputFile)) return "";
62
+ try {
63
+ const stat = fs.statSync(outputFile);
64
+ const ago = Date.now() - stat.mtimeMs;
65
+ if (ago < 1000) return "active now";
66
+ if (ago < 60000) return `active ${Math.floor(ago / 1000)}s ago`;
67
+ return `active ${Math.floor(ago / 60000)}m ago`;
68
+ } catch {
69
+ return "";
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Find a file/directory by prefix in a directory
75
+ */
76
+ export function findByPrefix(dir: string, prefix: string, suffix?: string): string | null {
77
+ if (!fs.existsSync(dir)) return null;
78
+ const entries = fs.readdirSync(dir).filter((entry) => entry.startsWith(prefix));
79
+ if (suffix) {
80
+ const withSuffix = entries.filter((entry) => entry.endsWith(suffix));
81
+ if (withSuffix.length > 0) return path.join(dir, withSuffix.sort()[0]);
82
+ }
83
+ if (entries.length === 0) return null;
84
+ return path.join(dir, entries.sort()[0]);
85
+ }
86
+
87
+ /**
88
+ * Find the latest session file in a directory
89
+ */
90
+ export function findLatestSessionFile(sessionDir: string): string | null {
91
+ if (!fs.existsSync(sessionDir)) return null;
92
+ const files = fs.readdirSync(sessionDir)
93
+ .filter((f) => f.endsWith(".jsonl"))
94
+ .map((f) => ({
95
+ name: f,
96
+ path: path.join(sessionDir, f),
97
+ mtime: fs.statSync(path.join(sessionDir, f)).mtimeMs,
98
+ }))
99
+ .sort((a, b) => b.mtime - a.mtime);
100
+ return files.length > 0 ? files[0].path : null;
101
+ }
102
+
103
+ /**
104
+ * Write a prompt to a temporary file
105
+ */
106
+ export function writePrompt(agent: string, prompt: string): { dir: string; path: string } {
107
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
108
+ const p = path.join(dir, `${agent.replace(/[^\w.-]/g, "_")}.md`);
109
+ fs.writeFileSync(p, prompt, { mode: 0o600 });
110
+ return { dir, path: p };
111
+ }
112
+
113
+ // ============================================================================
114
+ // Message Parsing Utilities
115
+ // ============================================================================
116
+
117
+ /**
118
+ * Get the final text output from a list of messages
119
+ */
120
+ export function getFinalOutput(messages: Message[]): string {
121
+ for (let i = messages.length - 1; i >= 0; i--) {
122
+ const msg = messages[i];
123
+ if (msg.role === "assistant") {
124
+ for (const part of msg.content) {
125
+ if (part.type === "text") return part.text;
126
+ }
127
+ }
128
+ }
129
+ return "";
130
+ }
131
+
132
+ /**
133
+ * Extract display items (text and tool calls) from messages
134
+ */
135
+ export function getDisplayItems(messages: Message[]): DisplayItem[] {
136
+ const items: DisplayItem[] = [];
137
+ for (const msg of messages) {
138
+ if (msg.role === "assistant") {
139
+ for (const part of msg.content) {
140
+ if (part.type === "text") items.push({ type: "text", text: part.text });
141
+ else if (part.type === "toolCall") items.push({ type: "tool", name: part.name, args: part.arguments });
142
+ }
143
+ }
144
+ }
145
+ return items;
146
+ }
147
+
148
+ /**
149
+ * Detect errors in subagent execution from messages
150
+ */
151
+ export function detectSubagentError(messages: Message[]): ErrorInfo {
152
+ for (const msg of messages) {
153
+ if (msg.role === "toolResult" && (msg as any).isError) {
154
+ const text = msg.content.find((c) => c.type === "text");
155
+ const details = text && "text" in text ? text.text : undefined;
156
+ const exitMatch = details?.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i);
157
+ return {
158
+ hasError: true,
159
+ exitCode: exitMatch ? parseInt(exitMatch[1], 10) : 1,
160
+ errorType: (msg as any).toolName || "tool",
161
+ details: details?.slice(0, 200),
162
+ };
163
+ }
164
+ }
165
+
166
+ for (const msg of messages) {
167
+ if (msg.role !== "toolResult") continue;
168
+ const toolName = (msg as any).toolName;
169
+ if (toolName !== "bash") continue;
170
+
171
+ const text = msg.content.find((c) => c.type === "text");
172
+ if (!text || !("text" in text)) continue;
173
+ const output = text.text;
174
+
175
+ const exitMatch = output.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i);
176
+ if (exitMatch) {
177
+ const code = parseInt(exitMatch[1], 10);
178
+ if (code !== 0) {
179
+ return { hasError: true, exitCode: code, errorType: "bash", details: output.slice(0, 200) };
180
+ }
181
+ }
182
+
183
+ const errorPatterns = [
184
+ /command not found/i,
185
+ /permission denied/i,
186
+ /no such file or directory/i,
187
+ /segmentation fault/i,
188
+ /killed|terminated/i,
189
+ /out of memory/i,
190
+ /connection refused/i,
191
+ /timeout/i,
192
+ ];
193
+ for (const pattern of errorPatterns) {
194
+ if (pattern.test(output)) {
195
+ return { hasError: true, exitCode: 1, errorType: "bash", details: output.slice(0, 200) };
196
+ }
197
+ }
198
+ }
199
+
200
+ return { hasError: false };
201
+ }
202
+
203
+ /**
204
+ * Extract a preview of tool arguments for display
205
+ */
206
+ export function extractToolArgsPreview(args: Record<string, unknown>): string {
207
+ // Handle MCP tool calls - show server/tool info
208
+ if (args.tool && typeof args.tool === "string") {
209
+ const server = args.server && typeof args.server === "string" ? `${args.server}/` : "";
210
+ const toolArgs = args.args && typeof args.args === "string" ? ` ${args.args.slice(0, 40)}` : "";
211
+ return `${server}${args.tool}${toolArgs}`;
212
+ }
213
+
214
+ const previewKeys = ["command", "path", "file_path", "pattern", "query", "url", "task", "describe", "search"];
215
+ for (const key of previewKeys) {
216
+ if (args[key] && typeof args[key] === "string") {
217
+ const value = args[key] as string;
218
+ return value.length > 60 ? `${value.slice(0, 57)}...` : value;
219
+ }
220
+ }
221
+
222
+ // Fallback: show first string value found
223
+ for (const [key, value] of Object.entries(args)) {
224
+ if (typeof value === "string" && value.length > 0) {
225
+ const preview = value.length > 50 ? `${value.slice(0, 47)}...` : value;
226
+ return `${key}=${preview}`;
227
+ }
228
+ }
229
+ return "";
230
+ }
231
+
232
+ /**
233
+ * Extract text content from various message content formats
234
+ */
235
+ export function extractTextFromContent(content: unknown): string {
236
+ if (!content) return "";
237
+ // Handle string content directly
238
+ if (typeof content === "string") return content;
239
+ // Handle array content
240
+ if (!Array.isArray(content)) return "";
241
+ const texts: string[] = [];
242
+ for (const part of content) {
243
+ if (part && typeof part === "object") {
244
+ // Handle { type: "text", text: "..." }
245
+ if ("type" in part && part.type === "text" && "text" in part) {
246
+ texts.push(String(part.text));
247
+ }
248
+ // Handle { type: "tool_result", content: "..." }
249
+ else if ("type" in part && part.type === "tool_result" && "content" in part) {
250
+ const inner = extractTextFromContent(part.content);
251
+ if (inner) texts.push(inner);
252
+ }
253
+ // Handle { text: "..." } without type
254
+ else if ("text" in part) {
255
+ texts.push(String(part.text));
256
+ }
257
+ }
258
+ }
259
+ return texts.join("\n");
260
+ }
261
+
262
+ // ============================================================================
263
+ // Concurrency Utilities
264
+ // ============================================================================
265
+
266
+ /**
267
+ * Map over items with limited concurrency
268
+ */
269
+ export async function mapConcurrent<T, R>(
270
+ items: T[],
271
+ limit: number,
272
+ fn: (item: T, i: number) => Promise<R>,
273
+ ): Promise<R[]> {
274
+ const results: R[] = new Array(items.length);
275
+ let next = 0;
276
+
277
+ async function worker(): Promise<void> {
278
+ while (next < items.length) {
279
+ const i = next++;
280
+ results[i] = await fn(items[i], i);
281
+ }
282
+ }
283
+
284
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
285
+ await Promise.all(workers);
286
+ return results;
287
+ }