@pi-archimedes/subagent 0.3.2 → 0.5.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "0.3.2"
14
+ "@pi-archimedes/core": "0.5.0"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@earendil-works/pi-coding-agent": ">=0.1.0",
package/src/agents.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Agent discovery and configuration loading.
3
+ * Reads agent definitions from ~/.pi/agent/agents/*.md and .pi/agents/*.md
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
9
+
10
+ export interface AgentConfig {
11
+ name: string;
12
+ description: string;
13
+ model?: string;
14
+ thinking?: string;
15
+ tools?: string[];
16
+ systemPrompt: string;
17
+ source: "user" | "project";
18
+ filePath: string;
19
+ }
20
+
21
+ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
22
+ const agents: AgentConfig[] = [];
23
+
24
+ if (!fs.existsSync(dir)) return agents;
25
+
26
+ let entries: fs.Dirent[];
27
+ try {
28
+ entries = fs.readdirSync(dir, { withFileTypes: true });
29
+ } catch {
30
+ return agents;
31
+ }
32
+
33
+ for (const entry of entries) {
34
+ if (!entry.name.endsWith(".md")) continue;
35
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
36
+
37
+ const filePath = path.join(dir, entry.name);
38
+ let content: string;
39
+ try {
40
+ content = fs.readFileSync(filePath, "utf-8");
41
+ } catch {
42
+ continue;
43
+ }
44
+
45
+ const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
46
+
47
+ if (!frontmatter.name || !frontmatter.description) continue;
48
+
49
+ const tools = typeof frontmatter.tools === "string"
50
+ ? frontmatter.tools.split(",").map((t: string) => t.trim()).filter(Boolean)
51
+ : undefined;
52
+
53
+ const config: AgentConfig = {
54
+ name: frontmatter.name as string,
55
+ description: frontmatter.description as string,
56
+ systemPrompt: body,
57
+ source,
58
+ filePath,
59
+ };
60
+ if (frontmatter.model) config.model = frontmatter.model as string;
61
+ if (frontmatter.thinking) config.thinking = frontmatter.thinking as string;
62
+ if (tools && tools.length > 0) config.tools = tools;
63
+
64
+ agents.push(config);
65
+ }
66
+
67
+ return agents;
68
+ }
69
+
70
+ function isDirectory(p: string): boolean {
71
+ try {
72
+ return fs.statSync(p).isDirectory();
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function findNearestProjectAgentsDir(cwd: string): string | null {
79
+ let currentDir = cwd;
80
+ while (true) {
81
+ const candidate = path.join(currentDir, ".pi", "agents");
82
+ if (isDirectory(candidate)) return candidate;
83
+
84
+ const parentDir = path.dirname(currentDir);
85
+ if (parentDir === currentDir) return null;
86
+ currentDir = parentDir;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Discover all available agents from user and/or project directories.
92
+ */
93
+ export function discoverAgents(cwd: string): AgentConfig[] {
94
+ const userDir = path.join(getAgentDir(), "agents");
95
+ const projectDir = findNearestProjectAgentsDir(cwd);
96
+
97
+ const userAgents = loadAgentsFromDir(userDir, "user");
98
+ const projectAgents = projectDir ? loadAgentsFromDir(projectDir, "project") : [];
99
+
100
+ // Project agents override user agents with the same name
101
+ const agentMap = new Map<string, AgentConfig>();
102
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
103
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
104
+
105
+ return Array.from(agentMap.values());
106
+ }
107
+
108
+ /**
109
+ * Look up an agent config by name.
110
+ */
111
+ export function findAgent(agents: AgentConfig[], name: string): AgentConfig | undefined {
112
+ return agents.find((a) => a.name === name);
113
+ }
package/src/compact.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Text } from "@earendil-works/pi-tui";
2
2
  import type { SubagentDetails, SubagentProgress, SubagentResult, SubagentToolResult } from "./types.js";
3
- import { SPINNER_FRAMES, formatTokens, formatDuration, formatCost, truncLine, buildStatsLine } from "./format.js";
3
+ import { formatTokens, formatDuration, formatCost, truncLine, buildStatsLine } from "./format.js";
4
4
 
5
5
  type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
6
6
  type RenderContext = { state: Record<string, unknown>; invalidate: () => void };
@@ -9,12 +9,12 @@ type RenderContext = { state: Record<string, unknown>; invalidate: () => void };
9
9
 
10
10
  export function buildActivityLine(
11
11
  progress: {
12
- currentTool?: string;
13
- currentToolArgs?: string;
14
- currentToolStartedAt?: number;
15
- finalOutput?: string;
16
- status?: "running" | "completed" | "failed";
17
- error?: string;
12
+ currentTool: string | undefined;
13
+ currentToolArgs: string | undefined;
14
+ currentToolStartedAt: number | undefined;
15
+ finalOutput: string | undefined;
16
+ status: "running" | "completed" | "failed" | undefined;
17
+ error: string | undefined;
18
18
  },
19
19
  theme: Theme,
20
20
  ): string {
@@ -26,7 +26,7 @@ export function buildActivityLine(
26
26
 
27
27
  if (progress.currentTool) {
28
28
  const argsPreview = progress.currentToolArgs
29
- ? truncLine(progress.currentToolArgs, 60)
29
+ ? truncLine(progress.currentToolArgs ?? "", 60)
30
30
  : "";
31
31
  const durationPart = progress.currentToolStartedAt
32
32
  ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
@@ -49,35 +49,13 @@ export function buildActivityLine(
49
49
  return "";
50
50
  }
51
51
 
52
- // ── Spinner helper ──────────────────────────────────────────────────────────
52
+ // ── Status glyph ────────────────────────────────────────────────────────────
53
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
- }
54
+ function statusGlyph(isRunning: boolean, status: string): string {
55
+ if (isRunning) return "↳";
68
56
  return status === "completed" ? "✓" : "✗";
69
57
  }
70
58
 
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
59
  // ── Compact single agent ────────────────────────────────────────────────────
82
60
 
83
61
  export function renderCompactSingle(
@@ -92,23 +70,13 @@ export function renderCompactSingle(
92
70
  const isRunning = progress?.status === "running";
93
71
  const status = isRunning ? "running" : (result.exitCode === 0 ? "completed" : "failed");
94
72
 
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
73
  // Track start time for live duration
103
74
  const timeKey = "_subagentStartTime_" + agentName;
104
- if (isRunning && !context.state[timeKey]) {
75
+ if (isRunning && context.state[timeKey] === undefined) {
105
76
  // Estimate start time from current duration
106
77
  const currentDuration = summary.durationMs || (progress?.durationMs ?? 0);
107
78
  context.state[timeKey] = Date.now() - currentDuration;
108
79
  }
109
- if (!isRunning) {
110
- delete context.state[timeKey];
111
- }
112
80
  const liveDuration = isRunning && context.state[timeKey]
113
81
  ? Date.now() - (context.state[timeKey] as number)
114
82
  : summary.durationMs;
@@ -124,14 +92,13 @@ export function renderCompactSingle(
124
92
 
125
93
  const statsPart = statsLine ?? "";
126
94
 
127
- // Activity: spinner + current tool if running, status if finished
95
+ // Activity: arrow + current tool if running, status if finished
128
96
  let activityLine: string;
129
97
  if (isRunning) {
130
- const spinner = SPINNER_FRAMES[(context.state["_subagentFrame_" + agentName] as number) ?? 0];
131
- const spinnerColored = theme.fg("muted", spinner);
98
+ const arrow = theme.fg("muted", "↳ ");
132
99
  if (progress?.currentTool) {
133
100
  const argsPreview = progress.currentToolArgs
134
- ? truncLine(progress.currentToolArgs, 60)
101
+ ? truncLine(progress.currentToolArgs ?? "", 60)
135
102
  : "";
136
103
  const durationPart = progress.currentToolStartedAt
137
104
  ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
@@ -143,10 +110,10 @@ export function renderCompactSingle(
143
110
  if (durationPart) {
144
111
  line += theme.fg("dim", durationPart);
145
112
  }
146
- activityLine = spinnerColored + " " + line;
113
+ activityLine = arrow + line;
147
114
  } else if (progress?.toolCalls && progress.toolCalls.length > 0) {
148
115
  const lastCall = progress.toolCalls[progress.toolCalls.length - 1];
149
- activityLine = theme.fg("dim", " ⎿ ") + theme.fg("muted", lastCall);
116
+ activityLine = theme.fg("dim", " ⎿ ") + theme.fg("muted", lastCall ?? "");
150
117
  } else {
151
118
  activityLine = theme.fg("muted", " ⎿ Working...");
152
119
  }
@@ -182,9 +149,10 @@ export function renderCompactParallel(
182
149
  const progress = details.progress?.[i];
183
150
  const agentName = result.agent ?? "agent-" + i;
184
151
  const summary = result.progressSummary ?? { toolCount: 0, tokens: 0, durationMs: 0 };
152
+ const isRunning = progress?.status === "running";
185
153
  const status = progress?.status ?? (result.exitCode === 0 ? "completed" : "failed");
186
154
 
187
- let glyph = status === "completed" ? "✓" : status === "failed" ? "✗" : "⠋";
155
+ const glyph = statusGlyph(isRunning, status);
188
156
  const glyphColored = status === "completed"
189
157
  ? theme.fg("success", glyph)
190
158
  : status === "failed"
@@ -234,7 +202,7 @@ export function renderCompactProgress(
234
202
  const status = progress.status;
235
203
  const isRunning = status === "running";
236
204
 
237
- let glyph = getSpinnerGlyph(agentName, isRunning, status, context);
205
+ const glyph = statusGlyph(isRunning, status);
238
206
  const glyphColored = status === "completed"
239
207
  ? theme.fg("success", glyph)
240
208
  : status === "failed"
@@ -243,13 +211,10 @@ export function renderCompactProgress(
243
211
 
244
212
  // Track start time for live duration
245
213
  const timeKey = "_subagentStartTime_" + agentName;
246
- if (isRunning && !context.state[timeKey]) {
214
+ if (isRunning && context.state[timeKey] === undefined) {
247
215
  context.state[timeKey] = Date.now() - (progress.durationMs ?? 0);
248
216
  }
249
- if (!isRunning) {
250
- delete context.state[timeKey];
251
- }
252
- const liveDuration = isRunning && context.state[timeKey]
217
+ const liveDuration = isRunning && context.state[timeKey] !== undefined
253
218
  ? Date.now() - (context.state[timeKey] as number)
254
219
  : progress.durationMs;
255
220
 
@@ -262,14 +227,13 @@ export function renderCompactProgress(
262
227
  };
263
228
  const statsLine = buildStatsLine(statsData, theme);
264
229
 
265
- // Activity: current tool if running, status if finished
230
+ // Activity: arrow + current tool if running, status if finished
266
231
  let activityLine: string;
267
232
  if (isRunning) {
268
- const spinner = SPINNER_FRAMES[(context.state["_subagentFrame_" + agentName] as number) ?? 0];
269
- const spinnerColored = theme.fg("muted", spinner);
233
+ const arrow = theme.fg("muted", "↳ ");
270
234
  if (progress.currentTool) {
271
235
  const argsPreview = progress.currentToolArgs
272
- ? truncLine(progress.currentToolArgs, 60)
236
+ ? truncLine(progress.currentToolArgs ?? "", 60)
273
237
  : "";
274
238
  const durationPart = progress.currentToolStartedAt
275
239
  ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
@@ -281,12 +245,12 @@ export function renderCompactProgress(
281
245
  if (durationPart) {
282
246
  line += theme.fg("dim", durationPart);
283
247
  }
284
- activityLine = spinnerColored + " " + line;
248
+ activityLine = arrow + line;
285
249
  } else if (progress.toolCalls && progress.toolCalls.length > 0) {
286
250
  const lastCall = progress.toolCalls[progress.toolCalls.length - 1];
287
- activityLine = spinnerColored + " " + theme.fg("muted", lastCall);
251
+ activityLine = arrow + theme.fg("muted", lastCall ?? "");
288
252
  } else {
289
- activityLine = spinnerColored + " " + theme.fg("muted", "Working...");
253
+ activityLine = arrow + theme.fg("muted", "Working...");
290
254
  }
291
255
  } else if (progress.error) {
292
256
  activityLine = theme.fg("error", "✗ " + truncLine(progress.error, 80));
@@ -321,7 +285,7 @@ export function renderCompactParallelProgress(
321
285
  const status = progress.status;
322
286
  const isRunning = status === "running";
323
287
 
324
- let glyph = getSpinnerGlyph(agentName, isRunning, status, context);
288
+ const glyph = statusGlyph(isRunning, status);
325
289
  const glyphColored = status === "completed"
326
290
  ? theme.fg("success", glyph)
327
291
  : status === "failed"
package/src/execute.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  import { spawnSubagent } from "./spawn.js";
2
2
  import { streamEvents } from "./stream.js";
3
3
  import { emitCostUpdate } from "./cost.js";
4
+ import type { AgentConfig } from "./agents.js";
4
5
  import type { SubagentProgress, SubagentResult, SubagentUsage } from "./types.js";
5
6
 
6
7
  export interface ExecuteOptions {
7
- agent?: string;
8
+ agent: string | undefined;
9
+ agentConfig: AgentConfig | undefined;
8
10
  task: string;
9
- model?: string;
10
- cwd?: string;
11
- signal?: AbortSignal;
12
- onUpdate?: (progress: SubagentProgress) => void;
11
+ model: string | undefined;
12
+ cwd: string | undefined;
13
+ signal: AbortSignal | undefined;
14
+ onUpdate: ((progress: SubagentProgress) => void) | undefined;
13
15
  }
14
16
 
15
17
  /**
@@ -24,6 +26,7 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
24
26
  model: options.model,
25
27
  cwd: options.cwd,
26
28
  signal: options.signal,
29
+ agent: options.agentConfig,
27
30
  });
28
31
 
29
32
  // Track previously emitted values to only emit deltas
@@ -80,7 +83,10 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
80
83
  cost: 0,
81
84
  turns: 0,
82
85
  } as SubagentUsage,
86
+ model: undefined,
87
+ finalOutput: undefined,
83
88
  error: err instanceof Error ? err.message : String(err),
89
+ progress: undefined,
84
90
  progressSummary: { toolCount: 0, tokens: 0, durationMs: Date.now() - startTime },
85
91
  };
86
92
  }
@@ -90,19 +96,23 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
90
96
  * Execute multiple subagents in parallel.
91
97
  */
92
98
  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;
99
+ tasks: Array<{ agent: string | undefined; agentConfig: AgentConfig | undefined; task: string; model: string | undefined; cwd: string | undefined }>;
100
+ signal: AbortSignal | undefined;
101
+ onUpdate: ((progress: SubagentProgress[]) => void) | undefined;
96
102
  }): Promise<SubagentResult[]> {
103
+ // Collect latest progress per agent for aggregated reporting
104
+ const latestProgress = new Map<string, SubagentProgress>();
105
+
97
106
  const results = await Promise.all(
98
107
  options.tasks.map((taskDef) =>
99
108
  executeSubagent({
100
109
  ...taskDef,
101
110
  signal: options.signal,
102
111
  onUpdate: (progress: SubagentProgress) => {
103
- // Collect all parallel progress updates
104
- // This is a simplified approach — each task fires independently
105
- options.onUpdate?.([progress]);
112
+ // Store latest progress keyed by agent name
113
+ latestProgress.set(progress.agent, progress);
114
+ // Emit aggregated progress across ALL agents
115
+ options.onUpdate?.([...latestProgress.values()]);
106
116
  },
107
117
  }),
108
118
  ),
package/src/expanded.ts CHANGED
@@ -40,7 +40,7 @@ export function buildExpandedText(
40
40
  if (toolCalls && toolCalls.length > 0) {
41
41
  lines.push("");
42
42
  for (const call of toolCalls) {
43
- lines.push(theme.fg("dim", "- " + call));
43
+ lines.push(theme.fg("dim", " " + call));
44
44
  }
45
45
  }
46
46
 
@@ -88,6 +88,7 @@ export function renderProgressExpanded(
88
88
  // Stats line (same as compact view)
89
89
  const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
90
90
  const statsLine = buildStatsLine({
91
+ turns: undefined,
91
92
  toolCount: progress.toolCount,
92
93
  tokens: progress.tokens,
93
94
  durationMs: progress.durationMs,
@@ -109,7 +110,7 @@ export function renderProgressExpanded(
109
110
  if (progress.toolCalls && progress.toolCalls.length > 0) {
110
111
  lines.push("");
111
112
  for (const call of progress.toolCalls) {
112
- lines.push(theme.fg("dim", "- " + call));
113
+ lines.push(theme.fg("dim", " " + call));
113
114
  }
114
115
  }
115
116
 
@@ -160,6 +161,7 @@ export function buildProgressExpandedText(
160
161
  // Stats line (same as compact view)
161
162
  const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
162
163
  const statsLine = buildStatsLine({
164
+ turns: undefined,
163
165
  toolCount: progress.toolCount,
164
166
  tokens: progress.tokens,
165
167
  durationMs: progress.durationMs,
@@ -181,7 +183,7 @@ export function buildProgressExpandedText(
181
183
  if (progress.toolCalls && progress.toolCalls.length > 0) {
182
184
  lines.push("");
183
185
  for (const call of progress.toolCalls) {
184
- lines.push(theme.fg("dim", "- " + call));
186
+ lines.push(theme.fg("dim", " " + call));
185
187
  }
186
188
  }
187
189
 
package/src/format.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  import { clampLine } from "@pi-archimedes/core/text";
2
2
 
3
- export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4
-
5
3
  export function formatTokens(n: number): string {
6
4
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
7
5
  if (n >= 1_000) return Math.round(n / 1_000) + "k";
@@ -9,7 +7,8 @@ export function formatTokens(n: number): string {
9
7
  }
10
8
 
11
9
  export function formatDuration(ms: number): string {
12
- if (ms < 1000) return "0s";
10
+ if (ms < 100) return "<0.1s";
11
+ if (ms < 1000) return (ms / 1000).toFixed(1) + "s";
13
12
  const seconds = Math.floor(ms / 1000);
14
13
  if (seconds < 60) return seconds + "s";
15
14
  const minutes = Math.floor(seconds / 60);
@@ -28,11 +27,11 @@ export function truncLine(text: string, width: number): string {
28
27
  }
29
28
 
30
29
  export interface StatsData {
31
- turns?: number;
32
- toolCount?: number;
33
- tokens?: number;
34
- durationMs?: number;
35
- cost?: number;
30
+ turns: number | undefined;
31
+ toolCount: number | undefined;
32
+ tokens: number | undefined;
33
+ durationMs: number | undefined;
34
+ cost: number | undefined;
36
35
  }
37
36
 
38
37
  export interface StatsTheme {
package/src/handlers.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import type { StreamState } from "./types.js";
2
2
 
3
+ // Truncation limits for previews
4
+ const ARGS_PREVIEW_MAX = 120;
5
+ const TOOL_CALLS_MAX = 50;
6
+ const RECENT_OUTPUT_MAX = 50;
7
+
3
8
  export interface JsonEvent {
4
9
  type: string;
5
10
  [key: string]: unknown;
@@ -10,14 +15,14 @@ export interface JsonEvent {
10
15
  * Generic: no hardcoded tool names.
11
16
  */
12
17
  export function extractArgsPreview(args: unknown): string {
13
- if (typeof args === "string") return args.slice(0, 120);
18
+ if (typeof args === "string") return args.slice(0, ARGS_PREVIEW_MAX);
14
19
  if (args && typeof args === "object" && !Array.isArray(args)) {
15
20
  const obj = args as Record<string, unknown>;
16
21
  const keys = Object.keys(obj);
17
22
  // Single-key object: just show the value
18
23
  if (keys.length === 1) {
19
- const v = obj[keys[0]];
20
- if (typeof v === "string") return v.slice(0, 120);
24
+ const v = obj[keys[0]!];
25
+ if (typeof v === "string") return v.slice(0, ARGS_PREVIEW_MAX);
21
26
  if (typeof v === "number" || typeof v === "boolean") return String(v);
22
27
  }
23
28
  // Multi-key: find the longest string value (likely the main payload)
@@ -27,9 +32,10 @@ export function extractArgsPreview(args: unknown): string {
27
32
  best = v;
28
33
  }
29
34
  }
30
- if (best) return best.slice(0, 120);
35
+ if (best) return best.slice(0, ARGS_PREVIEW_MAX);
31
36
  }
32
- return JSON.stringify(args)?.slice(0, 120) ?? "";
37
+ const serialized = JSON.stringify(args);
38
+ return serialized?.slice(0, ARGS_PREVIEW_MAX) ?? "";
33
39
  }
34
40
 
35
41
  /**
@@ -43,8 +49,8 @@ export function handleToolStart(state: StreamState, event: JsonEvent): void {
43
49
  // Record tool call with args preview
44
50
  const argsPreview = extractArgsPreview(event.args);
45
51
  state.toolCalls.push(`${state.currentTool}: ${argsPreview}`);
46
- if (state.toolCalls.length > 50) {
47
- state.toolCalls.splice(0, state.toolCalls.length - 50);
52
+ if (state.toolCalls.length > TOOL_CALLS_MAX) {
53
+ state.toolCalls.splice(0, state.toolCalls.length - TOOL_CALLS_MAX);
48
54
  }
49
55
  }
50
56
 
@@ -69,20 +75,20 @@ export function handleToolResult(state: StreamState, event: JsonEvent): void {
69
75
 
70
76
  if (typeof toolContent === "string" && toolContent.trim()) {
71
77
  const lines = toolContent.split("\n").filter((l) => l.trim());
72
- state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, 120)}`);
78
+ state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, ARGS_PREVIEW_MAX)}`);
73
79
  } else if (Array.isArray(toolContent)) {
74
80
  for (const part of toolContent) {
75
81
  if (part.type === "text" && (part.text as string)?.trim()) {
76
82
  const text = part.text as string;
77
83
  const lines = text.split("\n").filter((l) => l.trim());
78
- state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, 120)}`);
84
+ state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, ARGS_PREVIEW_MAX)}`);
79
85
  break;
80
86
  }
81
87
  }
82
88
  }
83
89
 
84
- if (state.recentOutput.length > 50) {
85
- state.recentOutput.splice(0, state.recentOutput.length - 50);
90
+ if (state.recentOutput.length > RECENT_OUTPUT_MAX) {
91
+ state.recentOutput.splice(0, state.recentOutput.length - RECENT_OUTPUT_MAX);
86
92
  }
87
93
  }
88
94
 
@@ -115,9 +121,9 @@ export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
115
121
  }
116
122
  }
117
123
 
118
- // Cap recentOutput at 50 lines
119
- if (state.recentOutput.length > 50) {
120
- state.recentOutput.splice(0, state.recentOutput.length - 50);
124
+ // Cap recentOutput
125
+ if (state.recentOutput.length > RECENT_OUTPUT_MAX) {
126
+ state.recentOutput.splice(0, state.recentOutput.length - RECENT_OUTPUT_MAX);
121
127
  }
122
128
 
123
129
  // Extract usage (turnCount tracked via turn_start in stream.ts)
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import { executeSubagent, executeParallel } from "./execute.js";
5
5
  import { renderSubagentResult } from "./render.js";
6
+ import { discoverAgents, findAgent } from "./agents.js";
6
7
  import type {
7
8
  SubagentDetails,
8
9
  SubagentProgress,
@@ -15,7 +16,6 @@ import type {
15
16
  const TaskItem = Type.Object({
16
17
  agent: Type.Optional(Type.String()),
17
18
  task: Type.String(),
18
- count: Type.Optional(Type.Number()),
19
19
  model: Type.Optional(Type.String()),
20
20
  cwd: Type.Optional(Type.String()),
21
21
  });
@@ -25,13 +25,13 @@ const SUBAGENT_PARAMS_SCHEMA = Type.Object({
25
25
  description: "Agent name/identifier (optional, defaults to 'general')",
26
26
  })),
27
27
  task: Type.Optional(Type.String({
28
- description: "Task description for the subagent",
28
+ description: "Task description for the subagent. Required when not using 'tasks' array.",
29
29
  })),
30
30
  tasks: Type.Optional(Type.Array(TaskItem, {
31
- description: "Multiple tasks for parallel execution",
31
+ description: "Multiple tasks for parallel execution. Required when not using 'task'.",
32
32
  })),
33
33
  model: Type.Optional(Type.String({
34
- description: "Model override (e.g. 'anthropic/claude-sonnet-4')",
34
+ description: "Model override for the subagent",
35
35
  })),
36
36
  async: Type.Optional(Type.Boolean({
37
37
  description: "Run asynchronously (fire-and-forget)",
@@ -55,7 +55,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
55
55
  name: "subagent",
56
56
  label: "Subagent",
57
57
  description:
58
- "Delegate tasks to subagents. Single: { agent, task }. Parallel: { tasks: [{ agent, task }] }. Options: model, cwd.",
58
+ "Delegate tasks to subagents. Provide either 'task' (single) or 'tasks' (parallel). Never omit both. Options: agent, model, cwd.",
59
59
  parameters: SUBAGENT_PARAMS_SCHEMA,
60
60
 
61
61
  async execute(
@@ -70,13 +70,36 @@ export function registerSubagent(pi: ExtensionAPI): void {
70
70
  },
71
71
  signal: AbortSignal | undefined,
72
72
  onUpdate: ((update: SubagentToolResult) => void) | undefined,
73
- _ctx: ExtensionContext,
73
+ ctx: ExtensionContext,
74
74
  ): Promise<SubagentToolResult> {
75
+ // Discover available agents
76
+ const agents = discoverAgents(ctx.cwd);
77
+
75
78
  // Parallel mode
76
79
  if (params.tasks && params.tasks.length > 0) {
80
+ const missingAgents = params.tasks.filter((t) => t.agent && !findAgent(agents, t.agent));
81
+ if (missingAgents.length > 0) {
82
+ const available = agents.map((a) => a.name).join(", ") || "none";
83
+ const unknown = missingAgents.map((t) => `"${t.agent}"`).join(", ");
84
+ return {
85
+ content: [{ type: "text", text: `Unknown agent(s): ${unknown}. Available: ${available}` }],
86
+ details: {
87
+ mode: "parallel",
88
+ results: [],
89
+ progress: undefined,
90
+ },
91
+ isError: true,
92
+ };
93
+ }
77
94
  const results: SubagentResult[] = await executeParallel({
78
- tasks: params.tasks,
79
- signal,
95
+ tasks: params.tasks.map((t) => ({
96
+ agent: t.agent ?? undefined,
97
+ agentConfig: t.agent ? findAgent(agents, t.agent) : undefined,
98
+ task: t.task,
99
+ model: t.model ?? undefined,
100
+ cwd: t.cwd ?? undefined,
101
+ })),
102
+ signal: signal ?? undefined,
80
103
  onUpdate: (progress: SubagentProgress[]) => {
81
104
  onUpdate?.({
82
105
  content: [],
@@ -101,12 +124,26 @@ export function registerSubagent(pi: ExtensionAPI): void {
101
124
 
102
125
  // Single mode
103
126
  if (params.task) {
127
+ let agentConfig = params.agent ? findAgent(agents, params.agent) : undefined;
128
+ if (params.agent && !agentConfig) {
129
+ const available = agents.map((a) => a.name).join(", ") || "none";
130
+ return {
131
+ content: [{ type: "text", text: `Unknown agent: "${params.agent}". Available: ${available}` }],
132
+ details: {
133
+ mode: "single",
134
+ results: [],
135
+ progress: undefined,
136
+ },
137
+ isError: true,
138
+ };
139
+ }
104
140
  const result: SubagentResult = await executeSubagent({
105
- agent: params.agent,
141
+ agent: params.agent ?? undefined,
142
+ agentConfig,
106
143
  task: params.task,
107
- model: params.model,
108
- cwd: params.cwd,
109
- signal,
144
+ model: params.model ?? undefined,
145
+ cwd: params.cwd ?? undefined,
146
+ signal: signal ?? undefined,
110
147
  onUpdate: (progress: SubagentProgress) => {
111
148
  onUpdate?.({
112
149
  content: [],
@@ -135,6 +172,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
135
172
  details: {
136
173
  mode: "single",
137
174
  results: [],
175
+ progress: undefined,
138
176
  },
139
177
  isError: true,
140
178
  };
package/src/render.ts CHANGED
@@ -40,7 +40,7 @@ export function renderSubagentResult(
40
40
  // ── Streaming progress (results empty but progress has data) ──────────
41
41
  if (details.results.length === 0 && details.progress && details.progress.length > 0) {
42
42
  if (details.progress.length === 1) {
43
- return renderProgressUpdate(text, details.progress[0], expanded, theme, context);
43
+ return renderProgressUpdate(text, details.progress[0]!, expanded, theme, context);
44
44
  }
45
45
  return renderProgressUpdatesParallel(text, details, expanded, theme, context);
46
46
  }
@@ -52,7 +52,7 @@ export function renderSubagentResult(
52
52
 
53
53
  // ── Single agent ────────────────────────────────────────────────────────
54
54
  if (details.mode === "single" || details.results.length === 1) {
55
- return renderSingleAgent(text, details.results[0], details.progress?.[0], expanded, theme, context);
55
+ return renderSingleAgent(text, details.results[0]!, details.progress?.[0], expanded, theme, context);
56
56
  }
57
57
 
58
58
  // ── Parallel agents ─────────────────────────────────────────────────────
package/src/spawn.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, writeFileSync, unlinkSync, mkdtempSync, rmdirSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { AgentConfig } from "./agents.js";
4
7
 
5
8
  export interface SpawnOptions {
6
9
  task: string;
7
- model?: string;
8
- cwd?: string;
9
- signal?: AbortSignal;
10
+ model: string | undefined;
11
+ cwd: string | undefined;
12
+ signal: AbortSignal | undefined;
13
+ agent: AgentConfig | undefined;
10
14
  }
11
15
 
12
16
  /**
@@ -32,6 +36,29 @@ export function resolvePiCommand(): { command: string; args: string[] } {
32
36
  return { command: piPath, args: [] };
33
37
  }
34
38
 
39
+ /**
40
+ * Write system prompt to a temp file for --append-system-prompt.
41
+ */
42
+ function writePromptToFile(agentName: string, prompt: string): { dir: string; filePath: string } {
43
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
44
+ const tmpDir = mkdtempSync(join(tmpdir(), "pi-subagent-"));
45
+ const filePath = join(tmpDir, `prompt-${safeName}.md`);
46
+ writeFileSync(filePath, prompt, { encoding: "utf-8" });
47
+ return { dir: tmpDir, filePath };
48
+ }
49
+
50
+ /**
51
+ * Clean up temp prompt file and directory.
52
+ */
53
+ function cleanupTempFiles(dir: string | null, filePath: string | null): void {
54
+ if (filePath) {
55
+ try { unlinkSync(filePath); } catch { /* ignore */ }
56
+ }
57
+ if (dir) {
58
+ try { rmdirSync(dir); } catch { /* ignore */ }
59
+ }
60
+ }
61
+
35
62
  /**
36
63
  * Spawn a child `pi` process in JSON mode.
37
64
  */
@@ -40,13 +67,42 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
40
67
  const args: string[] = [
41
68
  ...baseArgs,
42
69
  "--mode", "json",
43
- "-p", options.task,
70
+ "-p",
71
+ "--no-session",
44
72
  ];
45
73
 
46
- if (options.model) {
74
+ // Use agent config from frontmatter if available
75
+ if (options.agent) {
76
+ const agent = options.agent;
77
+ // Agent's model takes precedence; tool-call model as fallback
78
+ const modelToUse = agent.model ?? options.model;
79
+ if (modelToUse) {
80
+ args.push("--model", modelToUse);
81
+ }
82
+ if (agent.thinking) {
83
+ args.push("--thinking", agent.thinking);
84
+ }
85
+ if (agent.tools && agent.tools.length > 0) {
86
+ args.push("--tools", agent.tools.join(","));
87
+ }
88
+ } else if (options.model) {
89
+ // Fallback: use model from tool call params (legacy)
47
90
  args.push("--model", options.model);
48
91
  }
49
92
 
93
+ // Temp files for system prompt
94
+ let tmpDir: string | null = null;
95
+ let tmpPath: string | null = null;
96
+
97
+ if (options.agent && options.agent.systemPrompt.trim()) {
98
+ const tmp = writePromptToFile(options.agent.name, options.agent.systemPrompt);
99
+ tmpDir = tmp.dir;
100
+ tmpPath = tmp.filePath;
101
+ args.push("--append-system-prompt", tmpPath);
102
+ }
103
+
104
+ args.push(options.task);
105
+
50
106
  const child = spawn(command, args, {
51
107
  cwd: options.cwd || process.cwd(),
52
108
  stdio: ["ignore", "pipe", "pipe"],
@@ -54,8 +110,9 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
54
110
  });
55
111
 
56
112
  // Handle abort signal
113
+ let abortHandler: (() => void) | undefined;
57
114
  if (options.signal) {
58
- const cleanup = () => {
115
+ abortHandler = () => {
59
116
  if (child.pid && !child.killed) {
60
117
  child.kill("SIGTERM");
61
118
  const forceKill = setTimeout(() => {
@@ -66,8 +123,20 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
66
123
  forceKill.unref();
67
124
  }
68
125
  };
69
- options.signal.addEventListener("abort", cleanup, { once: true });
126
+ options.signal.addEventListener("abort", abortHandler, { once: true });
70
127
  }
71
128
 
129
+ // Clean up temp files and listeners on exit
130
+ const exitCleanup = (): void => {
131
+ child.removeListener("exit", exitCleanup);
132
+ child.removeListener("error", exitCleanup);
133
+ if (abortHandler && options.signal) {
134
+ options.signal.removeEventListener("abort", abortHandler);
135
+ }
136
+ cleanupTempFiles(tmpDir, tmpPath);
137
+ };
138
+ child.on("exit", exitCleanup);
139
+ child.on("error", exitCleanup);
140
+
72
141
  return child;
73
142
  }
package/src/stream.ts CHANGED
@@ -74,6 +74,9 @@ export function streamEvents(
74
74
  callbacks.onProgress?.(buildProgress());
75
75
  };
76
76
 
77
+ // Periodic progress updates for live duration display
78
+ const heartbeat = setInterval(emitProgress, 1000);
79
+
77
80
  // Collect stderr
78
81
  const stderrParts: string[] = [];
79
82
  child.stderr?.on("data", (data: Buffer) => {
@@ -129,6 +132,7 @@ export function streamEvents(
129
132
  // Handle process exit
130
133
  child.on("close", (code) => {
131
134
  clearTimeout(timeout);
135
+ clearInterval(heartbeat);
132
136
  const durationMs = Date.now() - startTime;
133
137
  const exitCode = code ?? 1;
134
138
 
@@ -171,6 +175,7 @@ export function streamEvents(
171
175
 
172
176
  child.on("error", (err) => {
173
177
  clearTimeout(timeout);
178
+ clearInterval(heartbeat);
174
179
  reject(err);
175
180
  });
176
181
  });
package/src/types.ts CHANGED
@@ -11,24 +11,24 @@ export interface SubagentProgress {
11
11
  agent: string;
12
12
  status: "running" | "completed" | "failed";
13
13
  task: string;
14
- currentTool?: string;
15
- currentToolArgs?: string;
16
- currentToolStartedAt?: number;
14
+ currentTool: string | undefined;
15
+ currentToolArgs: string | undefined;
16
+ currentToolStartedAt: number | undefined;
17
17
  toolCount: number;
18
18
  inputTokens: number;
19
19
  outputTokens: number;
20
20
  tokens: number;
21
21
  cost: number;
22
22
  durationMs: number;
23
- error?: string;
23
+ error: string | undefined;
24
24
  /** Model used by the subagent */
25
- model?: string;
25
+ model: string | undefined;
26
26
  /** Accumulated assistant text output during streaming */
27
- output?: string;
27
+ output: string | undefined;
28
28
  /** Last N lines of assistant text for live display */
29
- recentOutput?: string[];
29
+ recentOutput: string[] | undefined;
30
30
  /** History of tool calls: "toolName: args_preview" */
31
- toolCalls?: string[];
31
+ toolCalls: string[] | undefined;
32
32
  }
33
33
 
34
34
  export interface SubagentResult {
@@ -36,11 +36,11 @@ export interface SubagentResult {
36
36
  task: string;
37
37
  exitCode: number;
38
38
  usage: SubagentUsage;
39
- model?: string;
40
- finalOutput?: string;
41
- error?: string;
42
- progress?: SubagentProgress;
43
- progressSummary?: { toolCount: number; tokens: number; durationMs: number };
39
+ model: string | undefined;
40
+ finalOutput: string | undefined;
41
+ error: string | undefined;
42
+ progress: SubagentProgress | undefined;
43
+ progressSummary: { toolCount: number; tokens: number; durationMs: number } | undefined;
44
44
  }
45
45
 
46
46
  export interface SubagentToolResult {
@@ -52,7 +52,7 @@ export interface SubagentToolResult {
52
52
  export interface SubagentDetails {
53
53
  mode: "single" | "parallel";
54
54
  results: SubagentResult[];
55
- progress?: SubagentProgress[];
55
+ progress: SubagentProgress[] | undefined;
56
56
  }
57
57
 
58
58
  /** Mutable state during streaming — shared between stream.ts and handlers.ts */