@henryqw/pi-subagent 2.3.2 → 2.3.3
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/CONTEXT.md +1 -1
- package/README.md +1 -1
- package/extensions/subagent.ts +71 -6
- package/package.json +1 -1
package/CONTEXT.md
CHANGED
|
@@ -17,7 +17,7 @@ Provide validated user Roles, shared task-model Pi launch policy, generic manage
|
|
|
17
17
|
|
|
18
18
|
## Invariants
|
|
19
19
|
|
|
20
|
-
- One Delegated Task creates one ephemeral child process and no saved session.
|
|
20
|
+
- One Delegated Task creates one ephemeral child process and no saved session. Its soft deadline is 10 minutes; active model/tool execution or activity within the last minute grants one 5-minute grace period before a hard stop.
|
|
21
21
|
- Ambient child extensions and Skills stay disabled; Role explicitly selects extension sources and named Skills. Pi loads Skills supplied by those extension packages or their resource discovery. Omitted Role tools use Pi's effective `defaultTools`; an explicit list sets base tools while loaded extension tools activate automatically.
|
|
22
22
|
- Role Skill names resolve through Main's effective Pi Skill registry; unavailable names warn and skip without blocking delegation.
|
|
23
23
|
- Main selects Role and may override Model Class per task; omitted class uses shared `pi-subagent/delegateTask` assignment, initially `balanced`. Library callers select Role plus their own shared task ID.
|
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ pi install npm:@henryqw/pi-subagent
|
|
|
27
27
|
|
|
28
28
|
Main splits broad work into independent bounded tasks and keeps integration and cross-cutting decisions. Each `task` states its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation. Each call uses the least capable `modelClass` that can reliably complete its task. Independent sibling calls can run concurrently; concurrent edit tasks must own non-overlapping files.
|
|
29
29
|
|
|
30
|
-
Each call starts one isolated child (`pi --mode json -p --no-session`). Ambient extensions and Skills are off. Role/caller extensions load; those packages' tools and Skills auto-load, plus any extra `skills` names. Child uses the delegated working directory and Main's project approval. Abort kills the child process group. Streaming output is capped at 50 KiB. Unused JSON event types are discarded before payload buffering; consumed or unclassifiable events above 1 MiB fail delegation.
|
|
30
|
+
Each call starts one isolated child (`pi --mode json -p --no-session`). Ambient extensions and Skills are off. Role/caller extensions load; those packages' tools and Skills auto-load, plus any extra `skills` names. Child uses the delegated working directory and Main's project approval. Abort kills the child process group. An inactive child times out after 10 minutes; current model/tool execution or activity in the last minute grants one 5-minute grace period, then the child stops. Streaming output is capped at 50 KiB. Unused JSON event types are discarded before payload buffering; consumed or unclassifiable events above 1 MiB fail delegation.
|
|
31
31
|
|
|
32
32
|
TUI shows one row per Subagent with role, route, task, tokens, and elapsed time. Terminal rows drop after one second.
|
|
33
33
|
|
package/extensions/subagent.ts
CHANGED
|
@@ -18,9 +18,16 @@ const WIDGET_KEY = "subagent-status";
|
|
|
18
18
|
const WIDGET_INTERVAL_MS = 80;
|
|
19
19
|
const TERMINAL_DISPLAY_MS = 1_000;
|
|
20
20
|
const MAX_WIDGET_ROWS = 8;
|
|
21
|
+
const DEFAULT_TIMEOUT_POLICY = {
|
|
22
|
+
softMs: 10 * 60_000,
|
|
23
|
+
graceMs: 5 * 60_000,
|
|
24
|
+
activeWindowMs: 60_000,
|
|
25
|
+
};
|
|
21
26
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
22
27
|
|
|
28
|
+
type TimeoutPolicy = typeof DEFAULT_TIMEOUT_POLICY;
|
|
23
29
|
type ModelClass = ProfileName;
|
|
30
|
+
class SubagentTimeoutError extends Error {}
|
|
24
31
|
type ChildResult = {
|
|
25
32
|
exitCode: number;
|
|
26
33
|
output: string;
|
|
@@ -192,6 +199,7 @@ async function runPi(
|
|
|
192
199
|
signal: AbortSignal | undefined,
|
|
193
200
|
onUpdate: ((text: string) => void) | undefined,
|
|
194
201
|
onTokens: ((tokens: number) => void) | undefined,
|
|
202
|
+
timeoutPolicy: TimeoutPolicy,
|
|
195
203
|
): Promise<ChildResult> {
|
|
196
204
|
if (signal?.aborted) throw new Error("Subagent was aborted.");
|
|
197
205
|
return await new Promise<ChildResult>((resolve, reject) => {
|
|
@@ -207,6 +215,7 @@ async function runPi(
|
|
|
207
215
|
let lineParts: string[] = [];
|
|
208
216
|
let lineBytes = 0;
|
|
209
217
|
let linePrefix = "";
|
|
218
|
+
let lineEventType: string | undefined;
|
|
210
219
|
let ignoreLine = false;
|
|
211
220
|
let output = "";
|
|
212
221
|
const stderr = { prefix: "", totalBytes: 0 };
|
|
@@ -217,10 +226,23 @@ async function runPi(
|
|
|
217
226
|
let spawnError: Error | undefined;
|
|
218
227
|
let protocolError: Error | undefined;
|
|
219
228
|
let aborted = false;
|
|
229
|
+
let timedOutAfterMs: number | undefined;
|
|
230
|
+
let graceGranted = false;
|
|
231
|
+
let lastActivityAt = Date.now();
|
|
232
|
+
let modelActive = false;
|
|
233
|
+
let activeTools = 0;
|
|
220
234
|
let completedTokens = 0;
|
|
221
235
|
let currentTokens = 0;
|
|
236
|
+
let softDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
237
|
+
let hardDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
222
238
|
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
223
239
|
|
|
240
|
+
const observeEvent = (type: string) => {
|
|
241
|
+
lastActivityAt = Date.now();
|
|
242
|
+
if (type === "tool_execution_start") activeTools++;
|
|
243
|
+
else if (type === "tool_execution_end") activeTools = Math.max(0, activeTools - 1);
|
|
244
|
+
};
|
|
245
|
+
|
|
224
246
|
const processLine = (line: string) => {
|
|
225
247
|
if (!line.trim()) return;
|
|
226
248
|
let event: unknown;
|
|
@@ -232,12 +254,15 @@ async function runPi(
|
|
|
232
254
|
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
|
233
255
|
const record = event as Record<string, unknown>;
|
|
234
256
|
if (record.type === "message_start") {
|
|
257
|
+
if (record.message && typeof record.message === "object" && !Array.isArray(record.message)
|
|
258
|
+
&& (record.message as Record<string, unknown>).role === "assistant") modelActive = true;
|
|
235
259
|
partial.prefix = "";
|
|
236
260
|
partial.totalBytes = 0;
|
|
237
261
|
hasPartialText = false;
|
|
238
262
|
return;
|
|
239
263
|
}
|
|
240
264
|
if (record.type === "message_update") {
|
|
265
|
+
modelActive = true;
|
|
241
266
|
const tokens = usageTokens(record.usage);
|
|
242
267
|
if (tokens !== undefined) {
|
|
243
268
|
currentTokens = tokens;
|
|
@@ -266,6 +291,7 @@ async function runPi(
|
|
|
266
291
|
if (record.message && typeof record.message === "object" && !Array.isArray(record.message)) {
|
|
267
292
|
const message = record.message as Record<string, unknown>;
|
|
268
293
|
if (message.role === "assistant") {
|
|
294
|
+
modelActive = false;
|
|
269
295
|
completedTokens += usageTokens(message.usage) ?? currentTokens;
|
|
270
296
|
currentTokens = 0;
|
|
271
297
|
onTokens?.(completedTokens);
|
|
@@ -293,6 +319,7 @@ async function runPi(
|
|
|
293
319
|
|
|
294
320
|
child.stdout.on("data", (data: string) => {
|
|
295
321
|
if (protocolError) return;
|
|
322
|
+
lastActivityAt = Date.now();
|
|
296
323
|
let offset = 0;
|
|
297
324
|
while (offset < data.length) {
|
|
298
325
|
const newline = data.indexOf("\n", offset);
|
|
@@ -301,6 +328,10 @@ async function runPi(
|
|
|
301
328
|
if (!ignoreLine) {
|
|
302
329
|
linePrefix += part.slice(0, Math.max(0, 256 - linePrefix.length));
|
|
303
330
|
const eventType = JSON_EVENT_TYPE.exec(linePrefix)?.[1];
|
|
331
|
+
if (eventType && !lineEventType) {
|
|
332
|
+
lineEventType = eventType;
|
|
333
|
+
observeEvent(eventType);
|
|
334
|
+
}
|
|
304
335
|
if (eventType && !CONSUMED_JSON_EVENTS.has(eventType)) {
|
|
305
336
|
ignoreLine = true;
|
|
306
337
|
lineParts = [];
|
|
@@ -320,27 +351,57 @@ async function runPi(
|
|
|
320
351
|
lineParts = [];
|
|
321
352
|
lineBytes = 0;
|
|
322
353
|
linePrefix = "";
|
|
354
|
+
lineEventType = undefined;
|
|
323
355
|
ignoreLine = false;
|
|
324
356
|
offset = newline + 1;
|
|
325
357
|
}
|
|
326
358
|
});
|
|
327
|
-
child.stderr.on("data", (data: string) =>
|
|
359
|
+
child.stderr.on("data", (data: string) => {
|
|
360
|
+
lastActivityAt = Date.now();
|
|
361
|
+
appendBounded(stderr, data);
|
|
362
|
+
});
|
|
328
363
|
child.on("error", (error) => { spawnError = error; });
|
|
329
364
|
|
|
330
|
-
const
|
|
331
|
-
aborted = true;
|
|
365
|
+
const stop = () => {
|
|
332
366
|
killTree(false);
|
|
333
367
|
killTimer = setTimeout(() => killTree(true), 5_000);
|
|
334
368
|
killTimer.unref();
|
|
335
369
|
};
|
|
370
|
+
const abort = () => {
|
|
371
|
+
if (timedOutAfterMs !== undefined) return;
|
|
372
|
+
aborted = true;
|
|
373
|
+
stop();
|
|
374
|
+
};
|
|
375
|
+
const timeout = (afterMs: number) => {
|
|
376
|
+
if (aborted || timedOutAfterMs !== undefined) return;
|
|
377
|
+
timedOutAfterMs = afterMs;
|
|
378
|
+
stop();
|
|
379
|
+
};
|
|
380
|
+
softDeadlineTimer = setTimeout(() => {
|
|
381
|
+
const active = modelActive || activeTools > 0 || Date.now() - lastActivityAt <= timeoutPolicy.activeWindowMs;
|
|
382
|
+
if (active && timeoutPolicy.graceMs > 0) graceGranted = true;
|
|
383
|
+
else timeout(timeoutPolicy.softMs);
|
|
384
|
+
}, timeoutPolicy.softMs);
|
|
385
|
+
softDeadlineTimer.unref();
|
|
386
|
+
hardDeadlineTimer = setTimeout(
|
|
387
|
+
() => timeout(timeoutPolicy.softMs + timeoutPolicy.graceMs),
|
|
388
|
+
timeoutPolicy.softMs + timeoutPolicy.graceMs,
|
|
389
|
+
);
|
|
390
|
+
hardDeadlineTimer.unref();
|
|
336
391
|
signal?.addEventListener("abort", abort, { once: true });
|
|
392
|
+
if (signal?.aborted) abort();
|
|
337
393
|
|
|
338
394
|
child.on("close", (code) => {
|
|
339
395
|
if (!protocolError && lineBytes) processLine(lineParts.join(""));
|
|
340
|
-
if (aborted) killTree(true);
|
|
396
|
+
if (aborted || timedOutAfterMs !== undefined) killTree(true);
|
|
397
|
+
if (softDeadlineTimer) clearTimeout(softDeadlineTimer);
|
|
398
|
+
if (hardDeadlineTimer) clearTimeout(hardDeadlineTimer);
|
|
341
399
|
if (killTimer) clearTimeout(killTimer);
|
|
342
400
|
signal?.removeEventListener("abort", abort);
|
|
343
401
|
if (aborted) reject(new Error("Subagent was aborted."));
|
|
402
|
+
else if (timedOutAfterMs !== undefined) reject(new SubagentTimeoutError(
|
|
403
|
+
`Subagent timed out after ${formatElapsed(0, timedOutAfterMs)}${graceGranted ? ` despite active status at the ${formatElapsed(0, timeoutPolicy.softMs)} soft deadline` : " without active status at the soft deadline"}.`,
|
|
404
|
+
));
|
|
344
405
|
else if (protocolError) reject(protocolError);
|
|
345
406
|
else if (spawnError) reject(spawnError);
|
|
346
407
|
else resolve({ exitCode: code ?? 1, output, stderr: boundedText(stderr), stopReason, errorMessage });
|
|
@@ -367,7 +428,10 @@ const roleSummary = (): string => {
|
|
|
367
428
|
}
|
|
368
429
|
};
|
|
369
430
|
|
|
370
|
-
export default function subagentExtension(
|
|
431
|
+
export default function subagentExtension(
|
|
432
|
+
pi: ExtensionAPI,
|
|
433
|
+
timeoutPolicy: TimeoutPolicy = DEFAULT_TIMEOUT_POLICY,
|
|
434
|
+
): void {
|
|
371
435
|
const widgetItems = new Map<string, WidgetItem>();
|
|
372
436
|
let widgetInstalled = false;
|
|
373
437
|
let widgetTimer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -502,6 +566,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
502
566
|
signal,
|
|
503
567
|
(text) => onUpdate?.({ content: [{ type: "text", text }], details }),
|
|
504
568
|
(tokens) => updateWidgetTokens(toolCallId, tokens),
|
|
569
|
+
timeoutPolicy,
|
|
505
570
|
);
|
|
506
571
|
const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
507
572
|
widgetStatus = result.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
|
|
@@ -510,7 +575,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
510
575
|
: result.output || "(no output)");
|
|
511
576
|
return { content: [{ type: "text" as const, text }], details, ...(failed ? { isError: true } : {}) };
|
|
512
577
|
} catch (error) {
|
|
513
|
-
if (signal?.aborted) widgetStatus = "aborted";
|
|
578
|
+
if (signal?.aborted && !(error instanceof SubagentTimeoutError)) widgetStatus = "aborted";
|
|
514
579
|
throw error;
|
|
515
580
|
} finally {
|
|
516
581
|
finishWidgetItem(toolCallId, widgetStatus);
|