@pi-archimedes/subagent 0.7.0 → 0.8.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.7.0",
3
+ "version": "0.8.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.7.0"
14
+ "@pi-archimedes/core": "0.8.0"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@earendil-works/pi-coding-agent": ">=0.1.0",
package/src/compact.ts CHANGED
@@ -7,46 +7,61 @@ type RenderContext = { state: Record<string, unknown>; invalidate: () => void };
7
7
 
8
8
  // ── Activity line builder ───────────────────────────────────────────────────
9
9
 
10
+ interface ActivityData {
11
+ currentTool: string | undefined;
12
+ currentToolArgs: string | undefined;
13
+ currentToolStartedAt: number | undefined;
14
+ finalOutput: string | undefined;
15
+ status: "running" | "completed" | "failed" | undefined;
16
+ error: string | undefined;
17
+ toolCalls?: string[] | undefined;
18
+ }
19
+
10
20
  export function buildActivityLine(
11
- progress: {
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
- },
21
+ data: ActivityData,
19
22
  theme: Theme,
20
23
  ): string {
21
- const prefix = theme.fg("dim", " ⎿ ");
22
-
23
- if (progress.error) {
24
- return prefix + theme.fg("error", truncLine(progress.error, 80));
24
+ if (data.error) {
25
+ return theme.fg("error", "✗ " + truncLine(data.error, 80));
25
26
  }
26
27
 
27
- if (progress.currentTool) {
28
- const argsPreview = progress.currentToolArgs
29
- ? truncLine(progress.currentToolArgs ?? "", 60)
28
+ if (data.currentTool) {
29
+ const arrow = theme.fg("muted", "↳ ");
30
+ const argsPreview = data.currentToolArgs
31
+ ? truncLine(data.currentToolArgs ?? "", 60)
30
32
  : "";
31
- const durationPart = progress.currentToolStartedAt
32
- ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
33
+ const durationPart = data.currentToolStartedAt
34
+ ? " | " + formatDuration(Date.now() - data.currentToolStartedAt)
33
35
  : "";
34
- let line = theme.fg("syntaxFunction", progress.currentTool);
36
+ let line = theme.fg("syntaxFunction", data.currentTool);
35
37
  if (argsPreview) {
36
38
  line += theme.fg("dim", ": " + argsPreview);
37
39
  }
38
40
  if (durationPart) {
39
41
  line += theme.fg("dim", durationPart);
40
42
  }
41
- return prefix + line;
43
+ return arrow + line;
44
+ }
45
+
46
+ // Show last completed tool call from history
47
+ if (data.toolCalls && data.toolCalls.length > 0) {
48
+ const lastCall = data.toolCalls[data.toolCalls.length - 1];
49
+ if (lastCall) return theme.fg("muted", "↳ " + lastCall);
50
+ }
51
+
52
+ if (data.finalOutput) {
53
+ const firstLine = data.finalOutput.split("\n")[0] ?? "";
54
+ return theme.fg("muted", "↳ " + truncLine(firstLine, 80));
42
55
  }
43
56
 
44
- if (progress.finalOutput) {
45
- const firstLine = progress.finalOutput.split("\n")[0] ?? "";
46
- return prefix + truncLine(firstLine, 80);
57
+ // Running with no tool info yet
58
+ if (data.status === "running") {
59
+ return theme.fg("muted", "↳ Starting...");
47
60
  }
48
61
 
49
- return "";
62
+ return data.status === "completed"
63
+ ? theme.fg("success", "✓ Done")
64
+ : theme.fg("error", "✗ Failed");
50
65
  }
51
66
 
52
67
  // ── Status glyph ────────────────────────────────────────────────────────────
@@ -90,40 +105,18 @@ export function renderCompactSingle(
90
105
  };
91
106
  const statsLine = buildStatsLine(statsData, theme);
92
107
 
93
- const statsPart = statsLine ?? "";
108
+ const statsPart = statsLine;
94
109
 
95
110
  // Activity: arrow + current tool if running, status if finished
96
- let activityLine: string;
97
- if (isRunning) {
98
- const arrow = theme.fg("muted", "↳ ");
99
- if (progress?.currentTool) {
100
- const argsPreview = progress.currentToolArgs
101
- ? truncLine(progress.currentToolArgs ?? "", 60)
102
- : "";
103
- const durationPart = progress.currentToolStartedAt
104
- ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
105
- : "";
106
- let line = theme.fg("syntaxFunction", progress.currentTool);
107
- if (argsPreview) {
108
- line += theme.fg("dim", ": " + argsPreview);
109
- }
110
- if (durationPart) {
111
- line += theme.fg("dim", durationPart);
112
- }
113
- activityLine = arrow + line;
114
- } else if (progress?.toolCalls && progress.toolCalls.length > 0) {
115
- const lastCall = progress.toolCalls[progress.toolCalls.length - 1];
116
- activityLine = theme.fg("dim", " ⎿ ") + theme.fg("muted", lastCall ?? "");
117
- } else {
118
- activityLine = theme.fg("muted", " ⎿ Working...");
119
- }
120
- } else if (result.error) {
121
- activityLine = theme.fg("error", "✗ " + truncLine(result.error, 80));
122
- } else if (status === "completed") {
123
- activityLine = theme.fg("success", "✓ Done");
124
- } else {
125
- activityLine = theme.fg("error", "✗ Failed");
126
- }
111
+ const activityLine = buildActivityLine({
112
+ currentTool: progress?.currentTool,
113
+ currentToolArgs: progress?.currentToolArgs,
114
+ currentToolStartedAt: progress?.currentToolStartedAt,
115
+ finalOutput: result.finalOutput,
116
+ status: isRunning ? "running" : status,
117
+ error: result.error,
118
+ toolCalls: progress?.toolCalls,
119
+ }, theme);
127
120
 
128
121
  const modelName = progress?.model ?? result.model;
129
122
  const modelLabel = modelName
@@ -176,6 +169,7 @@ export function renderCompactParallel(
176
169
  finalOutput: result.finalOutput,
177
170
  status,
178
171
  error: result.error,
172
+ toolCalls: progress?.toolCalls,
179
173
  };
180
174
  const activityLine = buildActivityLine(activityData, theme);
181
175
 
@@ -228,42 +222,20 @@ export function renderCompactProgress(
228
222
  const statsLine = buildStatsLine(statsData, theme);
229
223
 
230
224
  // Activity: arrow + current tool if running, status if finished
231
- let activityLine: string;
232
- if (isRunning) {
233
- const arrow = theme.fg("muted", "↳ ");
234
- if (progress.currentTool) {
235
- const argsPreview = progress.currentToolArgs
236
- ? truncLine(progress.currentToolArgs ?? "", 60)
237
- : "";
238
- const durationPart = progress.currentToolStartedAt
239
- ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
240
- : "";
241
- let line = theme.fg("syntaxFunction", progress.currentTool);
242
- if (argsPreview) {
243
- line += theme.fg("dim", ": " + argsPreview);
244
- }
245
- if (durationPart) {
246
- line += theme.fg("dim", durationPart);
247
- }
248
- activityLine = arrow + line;
249
- } else if (progress.toolCalls && progress.toolCalls.length > 0) {
250
- const lastCall = progress.toolCalls[progress.toolCalls.length - 1];
251
- activityLine = arrow + theme.fg("muted", lastCall ?? "");
252
- } else {
253
- activityLine = arrow + theme.fg("muted", "Working...");
254
- }
255
- } else if (progress.error) {
256
- activityLine = theme.fg("error", "✗ " + truncLine(progress.error, 80));
257
- } else if (status === "completed") {
258
- activityLine = theme.fg("success", "✓ Done");
259
- } else {
260
- activityLine = theme.fg("error", "✗ Failed");
261
- }
225
+ const activityLine = buildActivityLine({
226
+ currentTool: progress.currentTool,
227
+ currentToolArgs: progress.currentToolArgs,
228
+ currentToolStartedAt: progress.currentToolStartedAt,
229
+ finalOutput: undefined,
230
+ status,
231
+ error: progress.error,
232
+ toolCalls: progress.toolCalls,
233
+ }, theme);
262
234
 
263
235
  const modelLabel = progress.model
264
236
  ? theme.fg("accent", progress.model)
265
237
  : "";
266
- const statsPart = statsLine ?? "";
238
+ const statsPart = statsLine;
267
239
  const expandHint = theme.fg("muted", "(ctrl+o)");
268
240
  let output = [modelLabel, statsPart, expandHint].filter(Boolean).join(" ");
269
241
  output += "\n" + activityLine;
@@ -309,6 +281,7 @@ export function renderCompactParallelProgress(
309
281
  finalOutput: undefined,
310
282
  status,
311
283
  error: progress.error,
284
+ toolCalls: progress.toolCalls,
312
285
  };
313
286
  const activityLine = buildActivityLine(activityData, theme);
314
287
 
package/src/execute.ts CHANGED
@@ -21,20 +21,20 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
21
21
  const agentName = options.agent ?? "subagent";
22
22
  const startTime = Date.now();
23
23
 
24
- const child = spawnSubagent({
25
- task: options.task,
26
- model: options.model,
27
- cwd: options.cwd,
28
- signal: options.signal,
29
- agent: options.agentConfig,
30
- });
31
-
32
24
  // Track previously emitted values to only emit deltas
33
25
  let lastEmittedInput = 0;
34
26
  let lastEmittedOutput = 0;
35
27
  let lastEmittedCost = 0;
36
28
 
37
29
  try {
30
+ const child = spawnSubagent({
31
+ task: options.task,
32
+ model: options.model,
33
+ cwd: options.cwd,
34
+ signal: options.signal,
35
+ agent: options.agentConfig,
36
+ });
37
+
38
38
  const result = await streamEvents(child, {
39
39
  agent: agentName,
40
40
  task: options.task,
package/src/handlers.ts CHANGED
@@ -19,11 +19,14 @@ export function extractArgsPreview(args: unknown): string {
19
19
  if (args && typeof args === "object" && !Array.isArray(args)) {
20
20
  const obj = args as Record<string, unknown>;
21
21
  const keys = Object.keys(obj);
22
- // Single-key object: just show the value
22
+ // Single-key object: show the value directly (or serialize if complex)
23
23
  if (keys.length === 1) {
24
24
  const v = obj[keys[0]!];
25
25
  if (typeof v === "string") return v.slice(0, ARGS_PREVIEW_MAX);
26
26
  if (typeof v === "number" || typeof v === "boolean") return String(v);
27
+ // Complex value (array/nested object) — serialize just this value
28
+ const serialized = JSON.stringify(v);
29
+ if (serialized) return serialized.slice(0, ARGS_PREVIEW_MAX);
27
30
  }
28
31
  // Multi-key: find the longest string value (likely the main payload)
29
32
  let best: string | undefined;
@@ -104,7 +107,7 @@ export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
104
107
  state.model = message.model as string;
105
108
  }
106
109
 
107
- // Collect text output
110
+ // Collect text + thinking output
108
111
  const content = message.content as Array<Record<string, unknown>> | string | undefined;
109
112
  if (typeof content === "string" && content.trim()) {
110
113
  state.accumulatedOutput.push(content);
@@ -117,6 +120,11 @@ export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
117
120
  state.accumulatedOutput.push(text);
118
121
  const lines = text.split("\n").filter((l) => l.trim());
119
122
  state.recentOutput.push(...lines.slice(-10));
123
+ } else if (part.type === "thinking" && (part.thinking as string)?.trim()) {
124
+ const thinking = part.thinking as string;
125
+ state.accumulatedOutput.push(`[thinking] ${thinking.trim()}`);
126
+ const lines = thinking.split("\n").filter((l) => l.trim());
127
+ state.recentOutput.push(...lines.slice(-5).map((l) => `[thinking] ${l}`));
120
128
  }
121
129
  }
122
130
  }
@@ -139,26 +147,28 @@ export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
139
147
  }
140
148
 
141
149
  /**
142
- * Handle an agent_end event — collect all assistant text for final output.
150
+ * Handle an agent_end event — extract final output from the last assistant message.
143
151
  */
144
152
  export function handleAgentEnd(state: StreamState, event: JsonEvent): void {
145
153
  const messages = event.messages as Array<Record<string, unknown>> | undefined;
146
154
  if (!messages || messages.length === 0) return;
147
155
 
156
+ // Use only the last assistant message for final output
157
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
158
+ if (!lastAssistant) return;
159
+
148
160
  const allText: string[] = [];
149
- for (const msg of messages) {
150
- if (msg.role === "assistant") {
151
- const content = msg.content as Array<Record<string, unknown>> | string | undefined;
152
- if (typeof content === "string" && content.trim()) {
153
- allText.push(content);
154
- } else if (Array.isArray(content)) {
155
- for (const part of content) {
156
- if (part.type === "text" && (part.text as string)?.trim()) {
157
- allText.push(part.text as string);
158
- }
159
- }
161
+ const content = lastAssistant.content as Array<Record<string, unknown>> | string | undefined;
162
+ if (typeof content === "string" && content.trim()) {
163
+ allText.push(content);
164
+ } else if (Array.isArray(content)) {
165
+ for (const part of content) {
166
+ if (part.type === "text" && (part.text as string)?.trim()) {
167
+ allText.push(part.text as string);
168
+ } else if (part.type === "thinking" && (part.thinking as string)?.trim()) {
169
+ allText.push(`[thinking] ${(part.thinking as string).trim()}`);
160
170
  }
161
171
  }
162
172
  }
163
- state.finalOutput = allText.join("\n\n");
173
+ state.finalOutput = allText.length > 0 ? allText.join("\n\n") : undefined;
164
174
  }
package/src/spawn.ts CHANGED
@@ -1,10 +1,21 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync, writeFileSync, unlinkSync, mkdtempSync, rmdirSync } from "node:fs";
3
+ import { existsSync, writeFileSync, unlinkSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import type { AgentConfig } from "./agents.js";
7
7
 
8
+ // Track all temp dirs for cleanup on process exit (graceful shutdown only).
9
+ // Note: SIGKILL/OOM cannot be caught — the exit handler only fires for
10
+ // normal exits and catchable signals (SIGTERM, SIGINT, SIGHUP, etc.).
11
+ const tempDirs = new Set<string>();
12
+
13
+ process.on("exit", () => {
14
+ for (const dir of tempDirs) {
15
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
16
+ }
17
+ });
18
+
8
19
  export interface SpawnOptions {
9
20
  task: string;
10
21
  model: string | undefined;
@@ -42,6 +53,7 @@ export function resolvePiCommand(): { command: string; args: string[] } {
42
53
  function writePromptToFile(agentName: string, prompt: string): { dir: string; filePath: string } {
43
54
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
44
55
  const tmpDir = mkdtempSync(join(tmpdir(), "pi-subagent-"));
56
+ tempDirs.add(tmpDir);
45
57
  const filePath = join(tmpDir, `prompt-${safeName}.md`);
46
58
  writeFileSync(filePath, prompt, { encoding: "utf-8" });
47
59
  return { dir: tmpDir, filePath };
@@ -55,7 +67,10 @@ function cleanupTempFiles(dir: string | null, filePath: string | null): void {
55
67
  try { unlinkSync(filePath); } catch { /* ignore */ }
56
68
  }
57
69
  if (dir) {
58
- try { rmdirSync(dir); } catch { /* ignore */ }
70
+ try {
71
+ rmdirSync(dir);
72
+ tempDirs.delete(dir);
73
+ } catch { /* leave in Set so exit handler can retry with rmSync */ }
59
74
  }
60
75
  }
61
76
 
package/src/stream.ts CHANGED
@@ -25,10 +25,29 @@ export function streamEvents(
25
25
  ): Promise<SubagentResult> {
26
26
  return new Promise((resolve, reject) => {
27
27
  const startTime = Date.now();
28
- const timeout = setTimeout(() => {
28
+
29
+ // Startup safeguard: if the child produces no JSON event within
30
+ // STARTUP_TIMEOUT_MS, kill it. This guards against hangs during pi
31
+ // initialization (model never loads, auth fails, etc.) and is the only
32
+ // automatic timeout. Once any event arrives the model is considered
33
+ // active and runtime is controlled entirely by the user's abort
34
+ // signal — a model that is REALLY thinking is left alone until the
35
+ // user explicitly cancels.
36
+ const STARTUP_TIMEOUT_MS = 2 * 60 * 1000;
37
+
38
+ let startupTimer: NodeJS.Timeout | undefined = setTimeout(() => {
29
39
  child.kill("SIGKILL");
30
- reject(new Error("subagent timed out after 5 minutes"));
31
- }, 5 * 60 * 1000);
40
+ reject(new Error(
41
+ `subagent timed out: no model output within ${STARTUP_TIMEOUT_MS / 60_000} minutes of startup`,
42
+ ));
43
+ }, STARTUP_TIMEOUT_MS);
44
+
45
+ const clearStartupTimer = (): void => {
46
+ if (startupTimer) {
47
+ clearTimeout(startupTimer);
48
+ startupTimer = undefined;
49
+ }
50
+ };
32
51
 
33
52
  const state: StreamState = {
34
53
  toolCount: 0,
@@ -97,6 +116,10 @@ export function streamEvents(
97
116
  return; // Skip non-JSON lines
98
117
  }
99
118
 
119
+ // First event received from the child means the model has engaged —
120
+ // from here on, the user controls lifetime via the abort signal.
121
+ clearStartupTimer();
122
+
100
123
  switch (event.type) {
101
124
  case "tool_execution_start": {
102
125
  handleToolStart(state, event);
@@ -131,7 +154,7 @@ export function streamEvents(
131
154
 
132
155
  // Handle process exit
133
156
  child.on("close", (code) => {
134
- clearTimeout(timeout);
157
+ clearStartupTimer();
135
158
  clearInterval(heartbeat);
136
159
  const durationMs = Date.now() - startTime;
137
160
  const exitCode = code ?? 1;
@@ -174,7 +197,7 @@ export function streamEvents(
174
197
  });
175
198
 
176
199
  child.on("error", (err) => {
177
- clearTimeout(timeout);
200
+ clearStartupTimer();
178
201
  clearInterval(heartbeat);
179
202
  reject(err);
180
203
  });