@bacnh85/pi-subagent 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +127 -4
- package/agent-format.md +12 -2
- package/extensions/agents.ts +90 -10
- package/extensions/index.ts +264 -215
- package/extensions/render.ts +7 -7
- package/extensions/runner.ts +88 -45
- package/extensions/security.ts +504 -0
- package/extensions/service.ts +29 -18
- package/extensions/thread-viewer.ts +2 -126
- package/extensions/threads.ts +1 -11
- package/package.json +26 -11
package/extensions/runner.ts
CHANGED
|
@@ -25,6 +25,11 @@ import {
|
|
|
25
25
|
SessionManager,
|
|
26
26
|
SettingsManager,
|
|
27
27
|
} from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import {
|
|
29
|
+
classifyStopReason,
|
|
30
|
+
createCombinedAbortSignal,
|
|
31
|
+
type SubagentStatus,
|
|
32
|
+
} from "./security.ts";
|
|
28
33
|
|
|
29
34
|
// ---------------------------------------------------------------------------
|
|
30
35
|
// Types
|
|
@@ -50,6 +55,8 @@ export interface SubAgentResult {
|
|
|
50
55
|
model?: string;
|
|
51
56
|
stopReason?: string;
|
|
52
57
|
errorMessage?: string;
|
|
58
|
+
/** Canonical result status (added in 0.6.0). */
|
|
59
|
+
status?: SubagentStatus;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
// ---------------------------------------------------------------------------
|
|
@@ -67,8 +74,9 @@ export async function runSubAgent(options: {
|
|
|
67
74
|
signal?: AbortSignal;
|
|
68
75
|
agentName?: string;
|
|
69
76
|
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
70
|
-
onUpdate?: (text: string) => void;
|
|
71
77
|
onMessage?: (partialResult: SubAgentResult) => void;
|
|
78
|
+
/** Pre-validated timeout in ms. When provided, an abort signal will be created. */
|
|
79
|
+
timeoutMs?: number;
|
|
72
80
|
}): Promise<SubAgentResult> {
|
|
73
81
|
const {
|
|
74
82
|
cwd,
|
|
@@ -81,8 +89,8 @@ export async function runSubAgent(options: {
|
|
|
81
89
|
signal,
|
|
82
90
|
agentName = "subagent",
|
|
83
91
|
thinkingLevel = "off",
|
|
84
|
-
onUpdate,
|
|
85
92
|
onMessage,
|
|
93
|
+
timeoutMs,
|
|
86
94
|
} = options;
|
|
87
95
|
|
|
88
96
|
const result: SubAgentResult = {
|
|
@@ -93,6 +101,7 @@ export async function runSubAgent(options: {
|
|
|
93
101
|
stderr: "",
|
|
94
102
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
95
103
|
model: `${model.provider}/${model.id}`,
|
|
104
|
+
status: undefined,
|
|
96
105
|
};
|
|
97
106
|
|
|
98
107
|
// Build a minimal resource loader. The sub-agent sees ONLY the agent's
|
|
@@ -114,11 +123,31 @@ export async function runSubAgent(options: {
|
|
|
114
123
|
retry: { enabled: false },
|
|
115
124
|
});
|
|
116
125
|
|
|
126
|
+
// Hoisted so the outer catch can clean up on early failure.
|
|
127
|
+
let timeoutController: AbortController | undefined;
|
|
128
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
129
|
+
let cleanupCombined: (() => void) | undefined;
|
|
130
|
+
|
|
117
131
|
try {
|
|
118
|
-
|
|
132
|
+
// Build combined signal from parent signal and timeout
|
|
133
|
+
const signalsToCombine: (AbortSignal | undefined | null | false)[] = [signal];
|
|
134
|
+
|
|
135
|
+
// Create timeout controller
|
|
136
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
137
|
+
timeoutController = new AbortController();
|
|
138
|
+
timeoutId = setTimeout(() => timeoutController!.abort(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
139
|
+
signalsToCombine.push(timeoutController.signal);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { signal: combinedSignal, cleanup: cleanupCb } = createCombinedAbortSignal(signalsToCombine);
|
|
143
|
+
cleanupCombined = cleanupCb;
|
|
144
|
+
|
|
145
|
+
if (combinedSignal.aborted) {
|
|
119
146
|
result.exitCode = 1;
|
|
120
|
-
|
|
121
|
-
result.
|
|
147
|
+
const isTimeout = timeoutController?.signal.aborted === true && signal?.aborted !== true;
|
|
148
|
+
result.stopReason = isTimeout ? "timeout" : "aborted";
|
|
149
|
+
result.errorMessage = combinedSignal.reason instanceof Error ? combinedSignal.reason.message : "Sub-agent aborted before start";
|
|
150
|
+
result.status = classifyStopReason(result.stopReason, !isTimeout, isTimeout);
|
|
122
151
|
return result;
|
|
123
152
|
}
|
|
124
153
|
|
|
@@ -136,21 +165,22 @@ export async function runSubAgent(options: {
|
|
|
136
165
|
|
|
137
166
|
let cleanupAbort: (() => void) | undefined;
|
|
138
167
|
let cleanupEventAbort: (() => void) | undefined;
|
|
168
|
+
let abortedBySignal = false;
|
|
169
|
+
let timedOut = false;
|
|
170
|
+
|
|
139
171
|
try {
|
|
140
|
-
// Wire abort signal
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return result;
|
|
150
|
-
}
|
|
151
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
152
|
-
cleanupAbort = () => signal.removeEventListener("abort", onAbort);
|
|
172
|
+
// Wire combined abort signal to session
|
|
173
|
+
const onAbort = () => {
|
|
174
|
+
session.abort();
|
|
175
|
+
};
|
|
176
|
+
if (combinedSignal.aborted) {
|
|
177
|
+
abortedBySignal = true;
|
|
178
|
+
timedOut = timeoutController?.signal.aborted === true && signal?.aborted !== true;
|
|
179
|
+
onAbort();
|
|
180
|
+
return result;
|
|
153
181
|
}
|
|
182
|
+
combinedSignal.addEventListener("abort", onAbort, { once: true });
|
|
183
|
+
cleanupAbort = () => combinedSignal.removeEventListener("abort", onAbort);
|
|
154
184
|
|
|
155
185
|
// Collect all messages and usage stats from events
|
|
156
186
|
const eventPromise = new Promise<void>((resolve, reject) => {
|
|
@@ -208,19 +238,16 @@ export async function runSubAgent(options: {
|
|
|
208
238
|
});
|
|
209
239
|
|
|
210
240
|
// Resolve on abort so the eventPromise doesn't hang
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
signal.addEventListener("abort", onAbortResolve, { once: true });
|
|
222
|
-
cleanupEventAbort = () => signal.removeEventListener("abort", onAbortResolve);
|
|
223
|
-
}
|
|
241
|
+
const onAbortResolve = () => {
|
|
242
|
+
finish(() => {
|
|
243
|
+
result.exitCode = 1;
|
|
244
|
+
if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
|
|
245
|
+
unsubscribe();
|
|
246
|
+
resolve();
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
combinedSignal.addEventListener("abort", onAbortResolve, { once: true });
|
|
250
|
+
cleanupEventAbort = () => combinedSignal.removeEventListener("abort", onAbortResolve);
|
|
224
251
|
});
|
|
225
252
|
|
|
226
253
|
await Promise.race([
|
|
@@ -228,13 +255,31 @@ export async function runSubAgent(options: {
|
|
|
228
255
|
eventPromise,
|
|
229
256
|
]);
|
|
230
257
|
|
|
231
|
-
|
|
258
|
+
// Detect timeout vs. parent abort.
|
|
259
|
+
timedOut = timeoutController?.signal.aborted === true && signal?.aborted !== true;
|
|
260
|
+
abortedBySignal = combinedSignal.aborted && !timedOut;
|
|
261
|
+
|
|
262
|
+
if (timedOut) {
|
|
263
|
+
result.exitCode = 1;
|
|
264
|
+
result.stopReason = "timeout";
|
|
265
|
+
result.errorMessage ||= `Timeout after ${timeoutMs}ms`;
|
|
266
|
+
} else if (abortedBySignal) {
|
|
267
|
+
result.exitCode = 1;
|
|
268
|
+
result.stopReason = "aborted";
|
|
269
|
+
result.errorMessage ||= "Sub-agent aborted";
|
|
270
|
+
} else {
|
|
232
271
|
result.exitCode = 0;
|
|
233
272
|
}
|
|
273
|
+
|
|
274
|
+
// Classify canonical status.
|
|
275
|
+
result.status = classifyStopReason(result.stopReason, result.stopReason === "aborted", result.stopReason === "timeout");
|
|
276
|
+
|
|
234
277
|
return result;
|
|
235
278
|
} finally {
|
|
236
279
|
cleanupAbort?.();
|
|
237
280
|
cleanupEventAbort?.();
|
|
281
|
+
cleanupCombined();
|
|
282
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
238
283
|
try {
|
|
239
284
|
session.dispose();
|
|
240
285
|
} catch {
|
|
@@ -246,6 +291,10 @@ export async function runSubAgent(options: {
|
|
|
246
291
|
result.exitCode = 1;
|
|
247
292
|
result.errorMessage = message;
|
|
248
293
|
if (!result.stopReason) result.stopReason = "error";
|
|
294
|
+
result.status = classifyStopReason("error", false, false);
|
|
295
|
+
// Ensure cleanup runs even when the outer try fails before the inner finally.
|
|
296
|
+
cleanupCombined?.();
|
|
297
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
249
298
|
return result;
|
|
250
299
|
}
|
|
251
300
|
}
|
|
@@ -255,31 +304,25 @@ export async function runSubAgent(options: {
|
|
|
255
304
|
// ---------------------------------------------------------------------------
|
|
256
305
|
|
|
257
306
|
export function getFinalOutput(messages: Message[]): string {
|
|
258
|
-
// Prefer the last assistant message with non-empty text and NO tool calls (pure final answer).
|
|
259
307
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
260
308
|
const msg = messages[i];
|
|
261
309
|
if (msg.role !== "assistant") continue;
|
|
262
310
|
const texts: string[] = [];
|
|
263
|
-
let hasToolCalls = false;
|
|
264
311
|
for (const part of msg.content) {
|
|
265
312
|
if (part.type === "text" && part.text.trim()) texts.push(part.text);
|
|
266
|
-
else if (part.type === "toolCall") hasToolCalls = true;
|
|
267
313
|
}
|
|
268
|
-
if (texts.length
|
|
269
|
-
|
|
270
|
-
// Fallback: last assistant message with any non-empty text (even if it also has tool calls).
|
|
271
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
272
|
-
const msg = messages[i];
|
|
273
|
-
if (msg.role !== "assistant") continue;
|
|
274
|
-
const texts = msg.content
|
|
275
|
-
.filter((p): p is { type: "text"; text: string } => p.type === "text" && p.text.trim().length > 0)
|
|
276
|
-
.map((p) => p.text);
|
|
277
|
-
if (texts.length > 0) return texts.join("");
|
|
314
|
+
if (texts.length === 0) continue;
|
|
315
|
+
return texts.join("");
|
|
278
316
|
}
|
|
279
317
|
return "";
|
|
280
318
|
}
|
|
281
319
|
|
|
282
320
|
export function isFailedResult(result: SubAgentResult): boolean {
|
|
321
|
+
// Use canonical status if available.
|
|
322
|
+
if (result.status) {
|
|
323
|
+
return result.status === "error" || result.status === "aborted" || result.status === "timeout";
|
|
324
|
+
}
|
|
325
|
+
// Fall back to legacy heuristics.
|
|
283
326
|
return (
|
|
284
327
|
result.exitCode !== 0 ||
|
|
285
328
|
result.stopReason === "error" ||
|