@henryqw/pi-subagent 2.11.7 → 3.0.0
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 +2 -2
- package/extensions/config.ts +18 -25
- package/extensions/subagent.ts +107 -62
- 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. 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
|
@@ -27,9 +27,9 @@ An explicit `model` (`provider/modelId`) overrides `modelClass` and resolves aga
|
|
|
27
27
|
|
|
28
28
|
`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
29
|
|
|
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: `{ "
|
|
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: `{ "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
31
|
|
|
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.
|
|
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. 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
33
|
|
|
34
34
|
TUI shows one row per Subagent with role, route, task, tokens, and elapsed time. Terminal rows drop after one second.
|
|
35
35
|
|
package/extensions/config.ts
CHANGED
|
@@ -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
|
-
/**
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
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 *
|
|
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
|
-
|
|
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
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
}
|
package/extensions/subagent.ts
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
256
|
-
let
|
|
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
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
405
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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);
|