@pi-archimedes/subagent 0.4.0 → 0.6.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.4.0",
3
+ "version": "0.6.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.4.0"
14
+ "@pi-archimedes/core": "0.6.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 };
@@ -49,46 +49,13 @@ export function buildActivityLine(
49
49
  return "";
50
50
  }
51
51
 
52
- // ── Spinner helper ──────────────────────────────────────────────────────────
52
+ // ── Status glyph ────────────────────────────────────────────────────────────
53
53
 
54
- const SPINNER_INTERVAL_MS = 80;
55
-
56
- function getSpinnerGlyph(agentName: string, isRunning: boolean, status: string, context: RenderContext): string {
57
- if (isRunning) {
58
- const timerKey = "_subagentTimer_" + agentName;
59
- const frameKey = "_subagentFrame_" + agentName;
60
- if (!context.state[timerKey]) {
61
- context.state[frameKey] = 0;
62
- const timer = setInterval(() => {
63
- context.state[frameKey] = ((context.state[frameKey] as number) + 1) % SPINNER_FRAMES.length;
64
- context.invalidate();
65
- }, SPINNER_INTERVAL_MS);
66
- context.state[timerKey] = timer;
67
- }
68
- return SPINNER_FRAMES[context.state[frameKey] as number]!;
69
- }
54
+ function statusGlyph(isRunning: boolean, status: string): string {
55
+ if (isRunning) return "↳";
70
56
  return status === "completed" ? "✓" : "✗";
71
57
  }
72
58
 
73
- function cleanupTimer(agentName: string, context: RenderContext): void {
74
- const timerKey = "_subagentTimer_" + agentName;
75
- const frameKey = "_subagentFrame_" + agentName;
76
- const timer = context.state[timerKey] as ReturnType<typeof setInterval> | undefined;
77
- if (timer) {
78
- clearInterval(timer);
79
- delete context.state[timerKey];
80
- }
81
- delete context.state[frameKey];
82
- }
83
-
84
- /** Cleanup ALL timers for a given agent — idempotent. */
85
- function cleanupAllTimers(agentName: string, context: RenderContext): void {
86
- cleanupTimer(agentName, context);
87
- // Also clean up start time tracker
88
- const timeKey = "_subagentStartTime_" + agentName;
89
- delete context.state[timeKey];
90
- }
91
-
92
59
  // ── Compact single agent ────────────────────────────────────────────────────
93
60
 
94
61
  export function renderCompactSingle(
@@ -103,13 +70,6 @@ export function renderCompactSingle(
103
70
  const isRunning = progress?.status === "running";
104
71
  const status = isRunning ? "running" : (result.exitCode === 0 ? "completed" : "failed");
105
72
 
106
- // Manage timer for spinner
107
- if (isRunning) {
108
- getSpinnerGlyph(agentName, true, "running", context);
109
- } else {
110
- cleanupAllTimers(agentName, context);
111
- }
112
-
113
73
  // Track start time for live duration
114
74
  const timeKey = "_subagentStartTime_" + agentName;
115
75
  if (isRunning && context.state[timeKey] === undefined) {
@@ -132,11 +92,10 @@ export function renderCompactSingle(
132
92
 
133
93
  const statsPart = statsLine ?? "";
134
94
 
135
- // Activity: spinner + current tool if running, status if finished
95
+ // Activity: arrow + current tool if running, status if finished
136
96
  let activityLine: string;
137
97
  if (isRunning) {
138
- const spinner = SPINNER_FRAMES[(context.state["_subagentFrame_" + agentName] as number) ?? 0];
139
- const spinnerColored = theme.fg("muted", spinner ?? "");
98
+ const arrow = theme.fg("muted", "↳ ");
140
99
  if (progress?.currentTool) {
141
100
  const argsPreview = progress.currentToolArgs
142
101
  ? truncLine(progress.currentToolArgs ?? "", 60)
@@ -151,7 +110,7 @@ export function renderCompactSingle(
151
110
  if (durationPart) {
152
111
  line += theme.fg("dim", durationPart);
153
112
  }
154
- activityLine = spinnerColored + " " + line;
113
+ activityLine = arrow + line;
155
114
  } else if (progress?.toolCalls && progress.toolCalls.length > 0) {
156
115
  const lastCall = progress.toolCalls[progress.toolCalls.length - 1];
157
116
  activityLine = theme.fg("dim", " ⎿ ") + theme.fg("muted", lastCall ?? "");
@@ -193,14 +152,7 @@ export function renderCompactParallel(
193
152
  const isRunning = progress?.status === "running";
194
153
  const status = progress?.status ?? (result.exitCode === 0 ? "completed" : "failed");
195
154
 
196
- // Use animated spinner for running agents
197
- let glyph: string;
198
- if (isRunning) {
199
- glyph = getSpinnerGlyph(agentName, true, "running", context);
200
- } else {
201
- cleanupTimer(agentName, context);
202
- glyph = status === "completed" ? "✓" : "✗";
203
- }
155
+ const glyph = statusGlyph(isRunning, status);
204
156
  const glyphColored = status === "completed"
205
157
  ? theme.fg("success", glyph)
206
158
  : status === "failed"
@@ -250,7 +202,7 @@ export function renderCompactProgress(
250
202
  const status = progress.status;
251
203
  const isRunning = status === "running";
252
204
 
253
- let glyph = getSpinnerGlyph(agentName, isRunning, status, context);
205
+ const glyph = statusGlyph(isRunning, status);
254
206
  const glyphColored = status === "completed"
255
207
  ? theme.fg("success", glyph)
256
208
  : status === "failed"
@@ -262,9 +214,6 @@ export function renderCompactProgress(
262
214
  if (isRunning && context.state[timeKey] === undefined) {
263
215
  context.state[timeKey] = Date.now() - (progress.durationMs ?? 0);
264
216
  }
265
- if (!isRunning) {
266
- cleanupAllTimers(agentName, context);
267
- }
268
217
  const liveDuration = isRunning && context.state[timeKey] !== undefined
269
218
  ? Date.now() - (context.state[timeKey] as number)
270
219
  : progress.durationMs;
@@ -278,11 +227,10 @@ export function renderCompactProgress(
278
227
  };
279
228
  const statsLine = buildStatsLine(statsData, theme);
280
229
 
281
- // Activity: current tool if running, status if finished
230
+ // Activity: arrow + current tool if running, status if finished
282
231
  let activityLine: string;
283
232
  if (isRunning) {
284
- const spinner = SPINNER_FRAMES[(context.state["_subagentFrame_" + agentName] as number) ?? 0];
285
- const spinnerColored = theme.fg("muted", spinner ?? "");
233
+ const arrow = theme.fg("muted", "↳ ");
286
234
  if (progress.currentTool) {
287
235
  const argsPreview = progress.currentToolArgs
288
236
  ? truncLine(progress.currentToolArgs ?? "", 60)
@@ -297,12 +245,12 @@ export function renderCompactProgress(
297
245
  if (durationPart) {
298
246
  line += theme.fg("dim", durationPart);
299
247
  }
300
- activityLine = spinnerColored + " " + line;
248
+ activityLine = arrow + line;
301
249
  } else if (progress.toolCalls && progress.toolCalls.length > 0) {
302
250
  const lastCall = progress.toolCalls[progress.toolCalls.length - 1];
303
- activityLine = spinnerColored + " " + theme.fg("muted", lastCall ?? "");
251
+ activityLine = arrow + theme.fg("muted", lastCall ?? "");
304
252
  } else {
305
- activityLine = spinnerColored + " " + theme.fg("muted", "Working...");
253
+ activityLine = arrow + theme.fg("muted", "Working...");
306
254
  }
307
255
  } else if (progress.error) {
308
256
  activityLine = theme.fg("error", "✗ " + truncLine(progress.error, 80));
@@ -337,7 +285,7 @@ export function renderCompactParallelProgress(
337
285
  const status = progress.status;
338
286
  const isRunning = status === "running";
339
287
 
340
- let glyph = getSpinnerGlyph(agentName, isRunning, status, context);
288
+ const glyph = statusGlyph(isRunning, status);
341
289
  const glyphColored = status === "completed"
342
290
  ? theme.fg("success", glyph)
343
291
  : status === "failed"
package/src/execute.ts CHANGED
@@ -1,10 +1,12 @@
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
8
  agent: string | undefined;
9
+ agentConfig: AgentConfig | undefined;
8
10
  task: string;
9
11
  model: string | undefined;
10
12
  cwd: string | undefined;
@@ -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
@@ -93,7 +96,7 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
93
96
  * Execute multiple subagents in parallel.
94
97
  */
95
98
  export async function executeParallel(options: {
96
- tasks: Array<{ agent: string | undefined; task: string; model: string | undefined; cwd: string | undefined }>;
99
+ tasks: Array<{ agent: string | undefined; agentConfig: AgentConfig | undefined; task: string; model: string | undefined; cwd: string | undefined }>;
97
100
  signal: AbortSignal | undefined;
98
101
  onUpdate: ((progress: SubagentProgress[]) => void) | undefined;
99
102
  }): Promise<SubagentResult[]> {
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
 
@@ -110,7 +110,7 @@ export function renderProgressExpanded(
110
110
  if (progress.toolCalls && progress.toolCalls.length > 0) {
111
111
  lines.push("");
112
112
  for (const call of progress.toolCalls) {
113
- lines.push(theme.fg("dim", "- " + call));
113
+ lines.push(theme.fg("dim", " " + call));
114
114
  }
115
115
  }
116
116
 
@@ -183,7 +183,7 @@ export function buildProgressExpandedText(
183
183
  if (progress.toolCalls && progress.toolCalls.length > 0) {
184
184
  lines.push("");
185
185
  for (const call of progress.toolCalls) {
186
- lines.push(theme.fg("dim", "- " + call));
186
+ lines.push(theme.fg("dim", " " + call));
187
187
  }
188
188
  }
189
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";
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,
@@ -24,13 +25,13 @@ const SUBAGENT_PARAMS_SCHEMA = Type.Object({
24
25
  description: "Agent name/identifier (optional, defaults to 'general')",
25
26
  })),
26
27
  task: Type.Optional(Type.String({
27
- description: "Task description for the subagent",
28
+ description: "Task description for the subagent. Required when not using 'tasks' array.",
28
29
  })),
29
30
  tasks: Type.Optional(Type.Array(TaskItem, {
30
- description: "Multiple tasks for parallel execution",
31
+ description: "Multiple tasks for parallel execution. Required when not using 'task'.",
31
32
  })),
32
33
  model: Type.Optional(Type.String({
33
- description: "Model override (e.g. 'anthropic/claude-sonnet-4')",
34
+ description: "Model override for the subagent",
34
35
  })),
35
36
  async: Type.Optional(Type.Boolean({
36
37
  description: "Run asynchronously (fire-and-forget)",
@@ -54,7 +55,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
54
55
  name: "subagent",
55
56
  label: "Subagent",
56
57
  description:
57
- "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.",
58
59
  parameters: SUBAGENT_PARAMS_SCHEMA,
59
60
 
60
61
  async execute(
@@ -69,13 +70,31 @@ export function registerSubagent(pi: ExtensionAPI): void {
69
70
  },
70
71
  signal: AbortSignal | undefined,
71
72
  onUpdate: ((update: SubagentToolResult) => void) | undefined,
72
- _ctx: ExtensionContext,
73
+ ctx: ExtensionContext,
73
74
  ): Promise<SubagentToolResult> {
75
+ // Discover available agents
76
+ const agents = discoverAgents(ctx.cwd);
77
+
74
78
  // Parallel mode
75
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
+ }
76
94
  const results: SubagentResult[] = await executeParallel({
77
95
  tasks: params.tasks.map((t) => ({
78
96
  agent: t.agent ?? undefined,
97
+ agentConfig: t.agent ? findAgent(agents, t.agent) : undefined,
79
98
  task: t.task,
80
99
  model: t.model ?? undefined,
81
100
  cwd: t.cwd ?? undefined,
@@ -105,8 +124,22 @@ export function registerSubagent(pi: ExtensionAPI): void {
105
124
 
106
125
  // Single mode
107
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
+ }
108
140
  const result: SubagentResult = await executeSubagent({
109
141
  agent: params.agent ?? undefined,
142
+ agentConfig,
110
143
  task: params.task,
111
144
  model: params.model ?? undefined,
112
145
  cwd: params.cwd ?? undefined,
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
10
  model: string | undefined;
8
11
  cwd: string | undefined;
9
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,13 +123,17 @@ 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
 
72
- // Clean up on normal exit to prevent forceKill firing after process ends
129
+ // Clean up temp files and listeners on exit
73
130
  const exitCleanup = (): void => {
74
131
  child.removeListener("exit", exitCleanup);
75
132
  child.removeListener("error", exitCleanup);
133
+ if (abortHandler && options.signal) {
134
+ options.signal.removeEventListener("abort", abortHandler);
135
+ }
136
+ cleanupTempFiles(tmpDir, tmpPath);
76
137
  };
77
138
  child.on("exit", exitCleanup);
78
139
  child.on("error", exitCleanup);
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
  });