@henryqw/pi-subagent 2.3.1 → 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 +3 -1
- package/extensions/subagent.ts +82 -8
- 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
|
@@ -25,7 +25,9 @@ pi install npm:@henryqw/pi-subagent
|
|
|
25
25
|
|
|
26
26
|
`modelClass` is `fast`, `balanced`, or `frontier`. Omitted class uses the shared `pi-subagent/delegateTask` assignment, which defaults to `balanced`. Primary route is resolved against current scoped text models; fallback is tried only before launch. If no route is usable, delegation rejects with `Run /task-models`. A started child is never retried.
|
|
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
|
+
|
|
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.
|
|
29
31
|
|
|
30
32
|
TUI shows one row per Subagent with role, route, task, tokens, and elapsed time. Terminal rows drop after one second.
|
|
31
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 });
|
|
@@ -350,7 +411,9 @@ async function runPi(
|
|
|
350
411
|
|
|
351
412
|
const Parameters = Type.Object({
|
|
352
413
|
role: Type.String({ description: "Configured Subagent role name" }),
|
|
353
|
-
task: Type.String({
|
|
414
|
+
task: Type.String({
|
|
415
|
+
description: "Bounded task packet: objective; exact scope and exclusions; relevant context and constraints; expected deliverable; validation. Never the whole parent request.",
|
|
416
|
+
}),
|
|
354
417
|
modelClass: Type.Optional(StringEnum(MODEL_CLASSES, {
|
|
355
418
|
description: "Classify task complexity: fast for narrow lookups or mechanical edits; balanced for normal bounded work; frontier for ambiguous, cross-cutting, or high-risk reasoning. Defaults to the shared pi-subagent/delegateTask assignment.",
|
|
356
419
|
})),
|
|
@@ -365,7 +428,10 @@ const roleSummary = (): string => {
|
|
|
365
428
|
}
|
|
366
429
|
};
|
|
367
430
|
|
|
368
|
-
export default function subagentExtension(
|
|
431
|
+
export default function subagentExtension(
|
|
432
|
+
pi: ExtensionAPI,
|
|
433
|
+
timeoutPolicy: TimeoutPolicy = DEFAULT_TIMEOUT_POLICY,
|
|
434
|
+
): void {
|
|
369
435
|
const widgetItems = new Map<string, WidgetItem>();
|
|
370
436
|
let widgetInstalled = false;
|
|
371
437
|
let widgetTimer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -457,7 +523,14 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
457
523
|
pi.registerTool({
|
|
458
524
|
name: "delegate_task",
|
|
459
525
|
label: "Subagent",
|
|
460
|
-
description: `Delegate one bounded task to one isolated Pi Subagent. Roles: ${roleSummary()}. Choose fast for narrow work, balanced for normal work, or frontier for ambiguous and high-risk work; omit modelClass to use shared task-model settings
|
|
526
|
+
description: `Delegate one bounded, independently executable task to one isolated Pi Subagent. Roles: ${roleSummary()}. Choose fast for narrow work, balanced for normal work, or frontier for ambiguous and high-risk work; omit modelClass to use shared task-model settings.`,
|
|
527
|
+
promptSnippet: "Delegate one bounded, independently executable task to an isolated role",
|
|
528
|
+
promptGuidelines: [
|
|
529
|
+
"Before calling delegate_task, split broad work into the smallest independent bounded tasks; keep integration and cross-cutting decisions in Main.",
|
|
530
|
+
"Each delegate_task task must state its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation; never pass the parent request unchanged.",
|
|
531
|
+
"For each delegate_task call, choose the least capable modelClass that can reliably complete the task: fast for narrow work, balanced for normal work, and frontier only for ambiguous, cross-cutting, or high-risk work.",
|
|
532
|
+
"Submit independent delegate_task calls together for parallel execution. Parallel edits must own non-overlapping files; otherwise sequence them. Use the minimum number of Subagents needed.",
|
|
533
|
+
],
|
|
461
534
|
parameters: Parameters,
|
|
462
535
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
463
536
|
const task = cleanText(params.task, "task", "delegate_task");
|
|
@@ -493,6 +566,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
493
566
|
signal,
|
|
494
567
|
(text) => onUpdate?.({ content: [{ type: "text", text }], details }),
|
|
495
568
|
(tokens) => updateWidgetTokens(toolCallId, tokens),
|
|
569
|
+
timeoutPolicy,
|
|
496
570
|
);
|
|
497
571
|
const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
498
572
|
widgetStatus = result.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
|
|
@@ -501,7 +575,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
501
575
|
: result.output || "(no output)");
|
|
502
576
|
return { content: [{ type: "text" as const, text }], details, ...(failed ? { isError: true } : {}) };
|
|
503
577
|
} catch (error) {
|
|
504
|
-
if (signal?.aborted) widgetStatus = "aborted";
|
|
578
|
+
if (signal?.aborted && !(error instanceof SubagentTimeoutError)) widgetStatus = "aborted";
|
|
505
579
|
throw error;
|
|
506
580
|
} finally {
|
|
507
581
|
finishWidgetItem(toolCallId, widgetStatus);
|