@pi-archimedes/subagent 0.6.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 +2 -2
- package/src/compact.ts +60 -87
- package/src/execute.ts +8 -8
- package/src/handlers.ts +25 -15
- package/src/spawn.ts +17 -2
- package/src/stream.ts +28 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/subagent",
|
|
3
|
-
"version": "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.
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
28
|
-
const
|
|
29
|
-
|
|
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 =
|
|
32
|
-
? " | " + formatDuration(Date.now() -
|
|
33
|
+
const durationPart = data.currentToolStartedAt
|
|
34
|
+
? " | " + formatDuration(Date.now() - data.currentToolStartedAt)
|
|
33
35
|
: "";
|
|
34
|
-
let line = theme.fg("syntaxFunction",
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
return
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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:
|
|
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 —
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
+
clearStartupTimer();
|
|
178
201
|
clearInterval(heartbeat);
|
|
179
202
|
reject(err);
|
|
180
203
|
});
|