@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 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
- 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.
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
 
@@ -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) => appendBounded(stderr, data));
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 abort = () => {
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({ description: "One bounded task with needed context and expected result" }),
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(pi: ExtensionAPI): void {
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. Request concise conclusions and file/line references; split broad scouting work.`,
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
4
4
  "description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
5
5
  "keywords": [
6
6
  "pi-package",