@henryqw/pi-subagent 2.11.7 → 3.0.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/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. Default timeouts: 10-minute soft deadline; active model/tool execution or activity within the last minute grants one 5-minute grace period before a hard stop. Every value is configurable via the `timeout` object in `~/.pi/agent/config/pi-subagent/pi-subagent.json` (`softMinutes`, `graceMinutes`, `activeWindowSeconds`).
20
+ - One Delegated Task creates one ephemeral child process and no saved session. Timeout behavior: `deadline = min(last recognized Pi JSON event + idle timeout, child start + maximum runtime)` (recognized Pi events renew; raw bytes do not; max always terminates). Configurable via the `timeout` object in `~/.pi/agent/config/pi-subagent/pi-subagent.json` (`idleMinutes`, `maxMinutes`; defaults 10/30).
21
21
  - Up to five active ephemeral `delegate_task` children run per Main by default, configurable via `maxSubagents` in `~/.pi/agent/config/pi-subagent/pi-subagent.json` or the `PI_SUBAGENT_MAX_SUBAGENTS` environment variable; excess calls wait FIFO. Queued calls do not start a child or consume child timeout. Managed Herdr workers are unaffected.
22
22
  - 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.
23
23
  - Role Skill names resolve through Main's effective Pi Skill registry; unavailable names warn and skip without blocking delegation.
package/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  Delegate one bounded task to one isolated Pi process, or reuse validated Role launch and managed Herdr hosting for durable workers. Main chooses Role and may override shared task-model effort per call.
4
4
 
5
+ ## Why
6
+
7
+ - **Created for**: Delegating bounded tasks to isolated child Pi processes while Main retains integration decisions.
8
+ - **Advantage**: Validated Roles, capped concurrency, timeouts, and managed Herdr hosting make delegation predictable.
9
+
5
10
  ## Install
6
11
 
7
12
  ```bash
@@ -27,9 +32,9 @@ An explicit `model` (`provider/modelId`) overrides `modelClass` and resolves aga
27
32
 
28
33
  `modelClass` is `fast`, `balanced`, `frontier`, or `fav`. 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.
29
34
 
30
- 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. Up to five active ephemeral `delegate_task` subagents run per Main; excess calls wait FIFO. Configure the cap with `"maxSubagents"` (positive integer) in `~/.pi/agent/config/pi-subagent/pi-subagent.json`, or override per session with the `PI_SUBAGENT_MAX_SUBAGENTS` environment variable. Child timeouts are configurable with a `"timeout"` object: `{ "softMinutes": 20, "graceMinutes": 10, "activeWindowSeconds": 90 }` (all keys optional; defaults 10/5/60). Invalid config values fall back to the default with a warning; an invalid environment variable fails fast. Queued calls do not start a child or consume child timeout. Managed Herdr workers are unaffected.
35
+ 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. Up to five active ephemeral `delegate_task` subagents run per Main; excess calls wait FIFO. Configure the cap with `"maxSubagents"` (positive integer) in `~/.pi/agent/config/pi-subagent/pi-subagent.json`, or override per session with the `PI_SUBAGENT_MAX_SUBAGENTS` environment variable. Child timeouts are configurable with a `"timeout"` object: `{ "idleMinutes": 10, "maxMinutes": 30 }` (all keys optional; defaults 10/30; the effective maximum must exceed the idle timeout). Invalid config values fall back to the default with a warning; an invalid environment variable fails fast. Queued calls do not start a child or consume child timeout. Managed Herdr workers are unaffected.
31
36
 
32
- 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.
37
+ 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. Child timeout behavior uses `deadline = min(last recognized Pi JSON event + idle timeout, child start + maximum runtime)`: recognized Pi events renew; raw bytes do not; max always terminates. 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.
33
38
 
34
39
  TUI shows one row per Subagent with role, route, task, tokens, and elapsed time. Terminal rows drop after one second.
35
40
 
@@ -3,12 +3,10 @@ import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  export interface SubagentTimeoutConfig {
6
- /** Soft deadline in minutes; the child is asked to stop after this. */
7
- softMinutes?: number;
8
- /** Extra minutes past the soft deadline before a stuck child is killed. */
9
- graceMinutes?: number;
10
- /** Activity window in seconds that qualifies an active child for grace. */
11
- activeWindowSeconds?: number;
6
+ /** Minutes a child may stay idle before it is asked to stop. */
7
+ idleMinutes?: number;
8
+ /** Hard cap in minutes before a child is killed regardless of activity. */
9
+ maxMinutes?: number;
12
10
  }
13
11
 
14
12
  export interface SubagentConfig {
@@ -28,14 +26,10 @@ const positive = (value: unknown): value is number =>
28
26
  // Node clamps setTimeout delays above 2^31 - 1 ms to 1 ms, which would kill
29
27
  // every child immediately instead of applying the configured deadline.
30
28
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
31
- // Defaults for fields the user left unset, used when bounding the combined
32
- // soft + grace hard-deadline delay.
33
- const DEFAULT_SOFT_MINUTES = 10;
34
- const DEFAULT_GRACE_MINUTES = 5;
35
- const TIMEOUT_FIELDS: Array<[keyof SubagentTimeoutConfig, number, string]> = [
36
- ["softMinutes", 60_000, "minutes"],
37
- ["graceMinutes", 60_000, "minutes"],
38
- ["activeWindowSeconds", 1_000, "seconds"],
29
+ export const DEFAULT_TIMEOUT_CONFIG = { idleMinutes: 10, maxMinutes: 30 } as const;
30
+ const TIMEOUT_FIELDS: Array<[keyof SubagentTimeoutConfig, string]> = [
31
+ ["idleMinutes", "minutes"],
32
+ ["maxMinutes", "minutes"],
39
33
  ];
40
34
 
41
35
  /**
@@ -94,15 +88,15 @@ export function readSubagentConfig(agentDir = getAgentDir()): LoadedSubagentConf
94
88
  } else {
95
89
  const timeoutRecord = record.timeout as Record<string, unknown>;
96
90
  const timeout: SubagentTimeoutConfig = {};
97
- for (const [key, unitMs, unit] of TIMEOUT_FIELDS) {
91
+ for (const [key, unit] of TIMEOUT_FIELDS) {
98
92
  const value = timeoutRecord[key];
99
93
  if (value === undefined) continue;
100
94
  if (!positive(value)) {
101
95
  problems.push(`timeout.${key} must be a positive number of ${unit}, got ${JSON.stringify(value)}`);
102
- } else if (value * unitMs > MAX_TIMER_DELAY_MS) {
96
+ } else if (value * 60_000 > MAX_TIMER_DELAY_MS) {
103
97
  problems.push(`timeout.${key} exceeds the maximum supported delay of ${MAX_TIMER_DELAY_MS} ms, got ${JSON.stringify(value)} ${unit}`);
104
98
  } else {
105
- (timeout as Record<string, number>)[key] = value;
99
+ timeout[key] = value;
106
100
  }
107
101
  }
108
102
  for (const key of Object.keys(timeoutRecord)) {
@@ -114,15 +108,14 @@ export function readSubagentConfig(agentDir = getAgentDir()): LoadedSubagentConf
114
108
  }
115
109
  }
116
110
 
117
- // The hard deadline schedules softMs + graceMs in one timer; the combination
118
- // must stay inside Node's limit even when each field fits alone. Falling back
119
- // to defaults drops the whole timeout object, matching other invalid values.
111
+ // The hard cap must exceed the idle deadline or every idle kill is
112
+ // unreachable. Falling back to defaults drops the whole timeout object,
113
+ // matching other invalid values.
120
114
  if (config.timeout) {
121
- const hardDeadlineMinutes =
122
- (config.timeout.softMinutes ?? DEFAULT_SOFT_MINUTES) +
123
- (config.timeout.graceMinutes ?? DEFAULT_GRACE_MINUTES);
124
- if (hardDeadlineMinutes * 60_000 > MAX_TIMER_DELAY_MS) {
125
- problems.push(`timeout softMinutes + graceMinutes must stay within ${MAX_TIMER_DELAY_MS} ms combined, got ${hardDeadlineMinutes} minutes`);
115
+ const idleMinutes = config.timeout.idleMinutes ?? DEFAULT_TIMEOUT_CONFIG.idleMinutes;
116
+ const maxMinutes = config.timeout.maxMinutes ?? DEFAULT_TIMEOUT_CONFIG.maxMinutes;
117
+ if (maxMinutes <= idleMinutes) {
118
+ problems.push(`timeout.maxMinutes (${maxMinutes}) must be greater than timeout.idleMinutes (${idleMinutes})`);
126
119
  delete config.timeout;
127
120
  }
128
121
  }
@@ -3,9 +3,9 @@ import { existsSync } from "node:fs";
3
3
  import { basename } from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
5
  import { StringEnum } from "@earendil-works/pi-ai";
6
- import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
6
+ import { type AgentSessionEvent, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
7
7
  import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
8
- import { readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
8
+ import { DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
9
9
  import {
10
10
  availableTaskModels,
11
11
  THINKING_LEVELS,
@@ -23,6 +23,31 @@ import { createChildWorktree, createRoleLaunch, finalizeChildWorktree, isProfile
23
23
  const SUBAGENT_TASK = "pi-subagent/delegateTask";
24
24
  const MAX_OUTPUT_BYTES = 50 * 1024;
25
25
  const MAX_JSON_EVENT_BYTES = 1024 * 1024;
26
+ const PI_JSON_EVENTS = {
27
+ agent_start: true,
28
+ agent_end: true,
29
+ agent_settled: true,
30
+ turn_start: true,
31
+ turn_end: true,
32
+ message_start: true,
33
+ message_update: true,
34
+ message_end: true,
35
+ tool_execution_start: true,
36
+ tool_execution_update: true,
37
+ tool_execution_end: true,
38
+ queue_update: true,
39
+ compaction_start: true,
40
+ compaction_end: true,
41
+ entry_appended: true,
42
+ session_info_changed: true,
43
+ thinking_level_changed: true,
44
+ auto_retry_start: true,
45
+ auto_retry_end: true,
46
+ summarization_retry_scheduled: true,
47
+ summarization_retry_attempt_start: true,
48
+ summarization_retry_finished: true,
49
+ bash_execution_update: true,
50
+ } satisfies Record<AgentSessionEvent["type"], true>;
26
51
  const CONSUMED_JSON_EVENTS = new Set(["message_start", "message_update", "message_end"]);
27
52
  const JSON_EVENT_TYPE = /^\s*\{\s*"type"\s*:\s*"([^"\\]+)"/;
28
53
  const WIDGET_KEY = "subagent-status";
@@ -30,9 +55,8 @@ const WIDGET_INTERVAL_MS = 80;
30
55
  const TERMINAL_DISPLAY_MS = 1_000;
31
56
  const MAX_WIDGET_ROWS = 8;
32
57
  const DEFAULT_TIMEOUT_POLICY = {
33
- softMs: 10 * 60_000,
34
- graceMs: 5 * 60_000,
35
- activeWindowMs: 60_000,
58
+ idleMs: DEFAULT_TIMEOUT_CONFIG.idleMinutes * 60_000,
59
+ maxMs: DEFAULT_TIMEOUT_CONFIG.maxMinutes * 60_000,
36
60
  };
37
61
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
38
62
 
@@ -40,12 +64,10 @@ type TimeoutPolicy = typeof DEFAULT_TIMEOUT_POLICY;
40
64
 
41
65
  /** Merge validated config-file timeout fields over defaults; absent keys keep defaults. */
42
66
  export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined): TimeoutPolicy {
43
- const policy: TimeoutPolicy = { ...DEFAULT_TIMEOUT_POLICY };
44
- if (!partial) return policy;
45
- if (partial.softMinutes !== undefined) policy.softMs = partial.softMinutes * 60_000;
46
- if (partial.graceMinutes !== undefined) policy.graceMs = partial.graceMinutes * 60_000;
47
- if (partial.activeWindowSeconds !== undefined) policy.activeWindowMs = partial.activeWindowSeconds * 1_000;
48
- return policy;
67
+ return {
68
+ idleMs: partial?.idleMinutes === undefined ? DEFAULT_TIMEOUT_POLICY.idleMs : partial.idleMinutes * 60_000,
69
+ maxMs: partial?.maxMinutes === undefined ? DEFAULT_TIMEOUT_POLICY.maxMs : partial.maxMinutes * 60_000,
70
+ };
49
71
  }
50
72
  class SubagentTimeoutError extends Error {}
51
73
  type ChildResult = {
@@ -251,21 +273,38 @@ async function runPi(
251
273
  let spawnError: Error | undefined;
252
274
  let protocolError: Error | undefined;
253
275
  let aborted = false;
276
+ const startedAt = Date.now();
277
+ const maxDeadline = startedAt + timeoutPolicy.maxMs;
278
+ let lastEventAt = startedAt;
279
+ let deadline = Math.min(startedAt + timeoutPolicy.idleMs, maxDeadline);
254
280
  let timedOutAfterMs: number | undefined;
255
- let graceGranted = false;
256
- let lastActivityAt = Date.now();
257
- let modelActive = false;
258
- let activeTools = 0;
281
+ let timeoutReason: "idle" | "maximum" | undefined;
282
+ let childExited = false;
259
283
  let completedTokens = 0;
260
284
  let currentTokens = 0;
261
- let softDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
262
- let hardDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
285
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
263
286
  let killTimer: ReturnType<typeof setTimeout> | undefined;
264
287
 
265
- const observeEvent = (type: string) => {
266
- lastActivityAt = Date.now();
267
- if (type === "tool_execution_start") activeTools++;
268
- else if (type === "tool_execution_end") activeTools = Math.max(0, activeTools - 1);
288
+ const scheduleDeadline = () => {
289
+ if (deadlineTimer) clearTimeout(deadlineTimer);
290
+ deadline = Math.min(lastEventAt + timeoutPolicy.idleMs, maxDeadline);
291
+ const scheduledDeadline = deadline;
292
+ deadlineTimer = setTimeout(
293
+ () => timeout(scheduledDeadline - startedAt, scheduledDeadline === maxDeadline ? "maximum" : "idle"),
294
+ Math.max(0, scheduledDeadline - Date.now()),
295
+ );
296
+ deadlineTimer.unref();
297
+ };
298
+
299
+ const observeEvent = () => {
300
+ if (aborted || timedOutAfterMs !== undefined || childExited) return;
301
+ const now = Date.now();
302
+ if (now >= deadline) {
303
+ timeout(deadline - startedAt, deadline === maxDeadline ? "maximum" : "idle");
304
+ return;
305
+ }
306
+ lastEventAt = now;
307
+ scheduleDeadline();
269
308
  };
270
309
 
271
310
  const processLine = (line: string) => {
@@ -278,16 +317,15 @@ async function runPi(
278
317
  }
279
318
  if (!event || typeof event !== "object" || Array.isArray(event)) return;
280
319
  const record = event as Record<string, unknown>;
320
+ if (typeof record.type !== "string" || !Object.hasOwn(PI_JSON_EVENTS, record.type)) return;
321
+ observeEvent();
281
322
  if (record.type === "message_start") {
282
- if (record.message && typeof record.message === "object" && !Array.isArray(record.message)
283
- && (record.message as Record<string, unknown>).role === "assistant") modelActive = true;
284
323
  partial.prefix = "";
285
324
  partial.totalBytes = 0;
286
325
  hasPartialText = false;
287
326
  return;
288
327
  }
289
328
  if (record.type === "message_update") {
290
- modelActive = true;
291
329
  const tokens = usageTokens(record.usage);
292
330
  if (tokens !== undefined) {
293
331
  currentTokens = tokens;
@@ -316,7 +354,6 @@ async function runPi(
316
354
  if (record.message && typeof record.message === "object" && !Array.isArray(record.message)) {
317
355
  const message = record.message as Record<string, unknown>;
318
356
  if (message.role === "assistant") {
319
- modelActive = false;
320
357
  completedTokens += usageTokens(message.usage) ?? currentTokens;
321
358
  currentTokens = 0;
322
359
  onTokens?.(completedTokens);
@@ -348,7 +385,6 @@ async function runPi(
348
385
 
349
386
  child.stdout.on("data", (data: string) => {
350
387
  if (protocolError) return;
351
- lastActivityAt = Date.now();
352
388
  let offset = 0;
353
389
  while (offset < data.length) {
354
390
  const newline = data.indexOf("\n", offset);
@@ -357,23 +393,19 @@ async function runPi(
357
393
  if (!ignoreLine) {
358
394
  linePrefix += part.slice(0, Math.max(0, 256 - linePrefix.length));
359
395
  const eventType = JSON_EVENT_TYPE.exec(linePrefix)?.[1];
360
- if (eventType && !lineEventType) {
361
- lineEventType = eventType;
362
- observeEvent(eventType);
363
- }
364
- if (eventType && !CONSUMED_JSON_EVENTS.has(eventType)) {
365
- ignoreLine = true;
366
- lineParts = [];
367
- lineBytes = 0;
368
- } else {
369
- lineBytes += Buffer.byteLength(part, "utf8");
370
- if (lineBytes > MAX_JSON_EVENT_BYTES) {
396
+ if (eventType && !lineEventType) lineEventType = eventType;
397
+ lineBytes += Buffer.byteLength(part, "utf8");
398
+ if (lineBytes > MAX_JSON_EVENT_BYTES) {
399
+ if (lineEventType && !CONSUMED_JSON_EVENTS.has(lineEventType)) {
400
+ ignoreLine = true;
401
+ lineParts = [];
402
+ lineBytes = 0;
403
+ } else {
371
404
  protocolError = new Error(`Subagent JSON event exceeds ${MAX_JSON_EVENT_BYTES} bytes.`);
372
405
  void killTree(true);
373
406
  return;
374
407
  }
375
- if (part) lineParts.push(part);
376
- }
408
+ } else if (part) lineParts.push(part);
377
409
  }
378
410
  if (newline === -1) return;
379
411
  if (!ignoreLine) processLine(lineParts.join(""));
@@ -386,52 +418,65 @@ async function runPi(
386
418
  }
387
419
  });
388
420
  child.stderr.on("data", (data: string) => {
389
- lastActivityAt = Date.now();
390
421
  appendBounded(stderr, data);
391
422
  });
392
423
  child.on("error", (error) => { spawnError = error; });
393
424
 
394
- const stop = () => {
425
+ const stop = (force = false) => {
426
+ if (force) {
427
+ void killTree(true);
428
+ return;
429
+ }
395
430
  void killTree(false);
396
- killTimer = setTimeout(() => void killTree(true), 5_000);
431
+ killTimer = setTimeout(
432
+ () => void killTree(true),
433
+ Math.min(5_000, Math.max(0, maxDeadline - Date.now())),
434
+ );
397
435
  killTimer.unref();
398
436
  };
399
437
  const abort = () => {
400
- if (timedOutAfterMs !== undefined) return;
438
+ if (timedOutAfterMs !== undefined || childExited) return;
401
439
  aborted = true;
402
440
  stop();
403
441
  };
404
- const timeout = (afterMs: number) => {
405
- if (aborted || timedOutAfterMs !== undefined) return;
442
+ function timeout(afterMs: number, reason: "idle" | "maximum") {
443
+ if (timedOutAfterMs !== undefined || childExited) return;
444
+ if (reason === "maximum") {
445
+ if (!aborted) {
446
+ timedOutAfterMs = afterMs;
447
+ timeoutReason = reason;
448
+ }
449
+ stop(true);
450
+ return;
451
+ }
452
+ if (aborted) return;
406
453
  timedOutAfterMs = afterMs;
454
+ timeoutReason = reason;
407
455
  stop();
408
- };
409
- softDeadlineTimer = setTimeout(() => {
410
- const active = modelActive || activeTools > 0 || Date.now() - lastActivityAt <= timeoutPolicy.activeWindowMs;
411
- if (active && timeoutPolicy.graceMs > 0) graceGranted = true;
412
- else timeout(timeoutPolicy.softMs);
413
- }, timeoutPolicy.softMs);
414
- softDeadlineTimer.unref();
415
- hardDeadlineTimer = setTimeout(
416
- () => timeout(timeoutPolicy.softMs + timeoutPolicy.graceMs),
417
- timeoutPolicy.softMs + timeoutPolicy.graceMs,
418
- );
419
- hardDeadlineTimer.unref();
456
+ }
457
+ scheduleDeadline();
420
458
  signal?.addEventListener("abort", abort, { once: true });
421
459
  if (signal?.aborted) abort();
422
460
 
461
+ // `close` waits for stdio EOF, which descendants can hold after Pi exits.
462
+ // Kill the process group at Pi's exit boundary so `close` can settle.
463
+ child.once("exit", () => {
464
+ childExited = true;
465
+ if (deadlineTimer) clearTimeout(deadlineTimer);
466
+ signal?.removeEventListener("abort", abort);
467
+ void killTree(true);
468
+ });
423
469
  child.on("close", async (code) => {
424
470
  if (!protocolError && lineBytes) processLine(lineParts.join(""));
425
- // Pi may exit while redirected background commands remain in its process
426
- // group. Stop every descendant before callers inspect or prune its cwd.
427
471
  await killTree(true);
428
- if (softDeadlineTimer) clearTimeout(softDeadlineTimer);
429
- if (hardDeadlineTimer) clearTimeout(hardDeadlineTimer);
472
+ if (deadlineTimer) clearTimeout(deadlineTimer);
430
473
  if (killTimer) clearTimeout(killTimer);
431
474
  signal?.removeEventListener("abort", abort);
432
475
  if (aborted) reject(new Error("Subagent was aborted."));
433
476
  else if (timedOutAfterMs !== undefined) reject(new SubagentTimeoutError(
434
- `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"}.`,
477
+ timeoutReason === "maximum"
478
+ ? `Subagent reached its maximum runtime after ${formatElapsed(0, timedOutAfterMs)}.`
479
+ : `Subagent timed out after ${formatElapsed(0, timeoutPolicy.idleMs)} without a recognized Pi event.`,
435
480
  ));
436
481
  else if (protocolError) reject(protocolError);
437
482
  else if (spawnError) reject(spawnError);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "2.11.7",
3
+ "version": "3.0.1",
4
4
  "description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
5
5
  "keywords": [
6
6
  "pi-package",