@hcflabs/pi-subagent 0.0.0-stage → 27.10.7

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.
Files changed (66) hide show
  1. package/CONTEXT.md +77 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -2
  4. package/dist/composition.d.ts +11 -0
  5. package/dist/composition.js +104 -0
  6. package/dist/direct-herdr.d.ts +71 -0
  7. package/dist/direct-herdr.js +527 -0
  8. package/dist/discard.d.ts +51 -0
  9. package/dist/discard.js +218 -0
  10. package/dist/display-text.d.ts +5 -0
  11. package/dist/display-text.js +9 -0
  12. package/dist/ephemeral.d.ts +79 -0
  13. package/dist/ephemeral.js +916 -0
  14. package/dist/git-process.d.ts +8 -0
  15. package/dist/git-process.js +25 -0
  16. package/dist/git-runtime.d.ts +115 -0
  17. package/dist/git-runtime.js +489 -0
  18. package/dist/herdr-runtime.d.ts +147 -0
  19. package/dist/herdr-runtime.js +1366 -0
  20. package/dist/index.d.ts +170 -0
  21. package/dist/index.js +587 -0
  22. package/dist/integration-git.d.ts +63 -0
  23. package/dist/integration-git.js +504 -0
  24. package/dist/launch-runtime.d.ts +54 -0
  25. package/dist/launch-runtime.js +178 -0
  26. package/dist/mcp-role.d.ts +12 -0
  27. package/dist/mcp-role.js +152 -0
  28. package/dist/process-lease.d.ts +5 -0
  29. package/dist/process-lease.js +43 -0
  30. package/dist/process.d.ts +9 -0
  31. package/dist/process.js +11 -0
  32. package/dist/prune.d.ts +78 -0
  33. package/dist/prune.js +205 -0
  34. package/dist/review-evidence.d.ts +17 -0
  35. package/dist/review-evidence.js +221 -0
  36. package/dist/runner.d.ts +431 -0
  37. package/dist/runner.js +3380 -0
  38. package/dist/schema.d.ts +2560 -0
  39. package/dist/schema.js +1365 -0
  40. package/dist/store.d.ts +66 -0
  41. package/dist/store.js +394 -0
  42. package/dist/worktree.d.ts +65 -0
  43. package/dist/worktree.js +288 -0
  44. package/docs/orchestration-architecture.html +138 -0
  45. package/docs/orchestration-architecture.svg +135 -0
  46. package/docs/orchestration.md +577 -0
  47. package/docs/worker-lifecycle.html +97 -0
  48. package/docs/worker-lifecycle.svg +86 -0
  49. package/example.png +0 -0
  50. package/examples/roles/implementer.md +25 -0
  51. package/examples/roles/reviewer.md +25 -0
  52. package/examples/roles/scout.md +25 -0
  53. package/extensions/admission.ts +82 -0
  54. package/extensions/config.ts +19 -0
  55. package/extensions/git-read.ts +126 -0
  56. package/extensions/isolated.ts +945 -0
  57. package/extensions/model-class-policy.ts +1 -0
  58. package/extensions/result-transport.ts +236 -0
  59. package/extensions/role-mcp.ts +14 -0
  60. package/extensions/role-tools.ts +178 -0
  61. package/extensions/subagent-command.ts +699 -0
  62. package/extensions/subagent.ts +958 -0
  63. package/extensions/workflow.ts +336 -0
  64. package/package.json +84 -4
  65. package/prompts/review.md +20 -0
  66. package/skills/pi-subagent/SKILL.md +41 -0
@@ -0,0 +1,916 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { basename } from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
5
+ import { hasDisplayControlCharacters } from "./display-text.js";
6
+ const MAX_OUTPUT_BYTES = 50 * 1024;
7
+ const MAX_JSON_EVENT_BYTES = 1024 * 1024;
8
+ export const DEFAULT_MAX_TURNS = 50;
9
+ export const EXECUTION_BUDGET_ENV = "PI_SUBAGENT_EXECUTION_BUDGET";
10
+ const MAX_ACTIVITY_TEXT_BYTES = 4 * 1024;
11
+ // A JSON string byte can take six source bytes (for example, \u0000).
12
+ const MAX_ACTIVITY_PREFIX_BYTES = 2 * MAX_ACTIVITY_TEXT_BYTES * 6 + 1024;
13
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
14
+ const POST_EXIT_STDIO_IDLE_MS = 250;
15
+ const POST_EXIT_STDIO_HARD_MS = 1_000;
16
+ const PI_JSON_EVENTS = {
17
+ agent_start: true,
18
+ agent_end: true,
19
+ agent_settled: true,
20
+ turn_start: true,
21
+ turn_end: true,
22
+ message_start: true,
23
+ message_update: true,
24
+ message_end: true,
25
+ tool_execution_start: true,
26
+ tool_execution_update: true,
27
+ tool_execution_end: true,
28
+ queue_update: true,
29
+ compaction_start: true,
30
+ compaction_end: true,
31
+ entry_appended: true,
32
+ session_info_changed: true,
33
+ thinking_level_changed: true,
34
+ auto_retry_start: true,
35
+ auto_retry_end: true,
36
+ summarization_retry_scheduled: true,
37
+ summarization_retry_attempt_start: true,
38
+ summarization_retry_finished: true,
39
+ bash_execution_update: true,
40
+ };
41
+ const CONSUMED_JSON_EVENTS = new Set(["message_start", "message_update", "message_end", "agent_settled"]);
42
+ const JSON_EVENT_TYPE = /^\s*\{\s*"type"\s*:\s*"([^"\\]+)"/;
43
+ const JSON_STRING = `"(?:[^"\\\\\u0000-\u001f]|\\\\(?:["\\\\/bfnrt]|u[0-9a-fA-F]{4}))*"`;
44
+ const JSON_OVERSIZED_TOOL_START = new RegExp(`^\\s*\\{\\s*"type"\\s*:\\s*"tool_execution_start"\\s*,\\s*"toolCallId"\\s*:\\s*(${JSON_STRING})\\s*,\\s*"toolName"\\s*:\\s*(${JSON_STRING})(?=\\s*,)`);
45
+ export class EphemeralSubagentError extends Error {
46
+ name = "EphemeralSubagentError";
47
+ code;
48
+ usage;
49
+ output;
50
+ constructor(code, message, cause, usage, output) {
51
+ super(message, cause === undefined ? undefined : { cause });
52
+ this.code = code;
53
+ this.usage = usage;
54
+ this.output = output;
55
+ }
56
+ }
57
+ function positiveDelay(value, field) {
58
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) {
59
+ throw new RangeError(`${field} must be a positive number no greater than ${MAX_TIMER_DELAY_MS}.`);
60
+ }
61
+ return value;
62
+ }
63
+ function validateOptions(options) {
64
+ if (!options || typeof options !== "object")
65
+ throw new TypeError("Ephemeral Subagent executor options are required.");
66
+ if (!Number.isSafeInteger(options.maxConcurrency) || options.maxConcurrency < 1) {
67
+ throw new RangeError("maxConcurrency must be a positive safe integer.");
68
+ }
69
+ const maxTurns = options.maxTurns === undefined ? DEFAULT_MAX_TURNS : options.maxTurns;
70
+ if (!Number.isSafeInteger(maxTurns) || maxTurns < 1) {
71
+ throw new RangeError("maxTurns must be a safe integer >= 1.");
72
+ }
73
+ if (options.maxTokens !== undefined && (!Number.isSafeInteger(options.maxTokens) || options.maxTokens < 1)) {
74
+ throw new RangeError("maxTokens must be a safe integer >= 1.");
75
+ }
76
+ if (!options.timeout || typeof options.timeout !== "object")
77
+ throw new TypeError("timeout is required.");
78
+ const timeout = {
79
+ idleMs: positiveDelay(options.timeout.idleMs, "timeout.idleMs"),
80
+ maxMs: options.timeout.maxMs === null ? null : positiveDelay(options.timeout.maxMs, "timeout.maxMs"),
81
+ };
82
+ if (timeout.maxMs !== null && timeout.maxMs <= timeout.idleMs)
83
+ throw new RangeError("timeout.maxMs must be greater than timeout.idleMs.");
84
+ return {
85
+ maxConcurrency: options.maxConcurrency,
86
+ maxTurns,
87
+ ...(options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }),
88
+ timeout,
89
+ };
90
+ }
91
+ function abortError(signal, cause = signal?.reason, usage) {
92
+ return new EphemeralSubagentError("aborted", "Subagent was aborted.", cause, usage);
93
+ }
94
+ function validateRunInput(value) {
95
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
96
+ throw new TypeError("Ephemeral Subagent run input must be an object.");
97
+ }
98
+ const input = value;
99
+ if (typeof input.prepare !== "function")
100
+ throw new TypeError("run.prepare must be a function.");
101
+ if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) {
102
+ throw new TypeError("run.signal must be an AbortSignal.");
103
+ }
104
+ if (input.onUpdate !== undefined && typeof input.onUpdate !== "function") {
105
+ throw new TypeError("run.onUpdate must be a function.");
106
+ }
107
+ if (input.onTokens !== undefined && typeof input.onTokens !== "function") {
108
+ throw new TypeError("run.onTokens must be a function.");
109
+ }
110
+ if (input.onActivity !== undefined && typeof input.onActivity !== "function") {
111
+ throw new TypeError("run.onActivity must be a function.");
112
+ }
113
+ return value;
114
+ }
115
+ function record(value, field) {
116
+ if (!value || typeof value !== "object" || Array.isArray(value))
117
+ throw new TypeError(`${field} must be an object.`);
118
+ return value;
119
+ }
120
+ function preparedText(value, field) {
121
+ if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
122
+ throw new TypeError(`${field} must be non-empty text without NUL bytes.`);
123
+ }
124
+ return value;
125
+ }
126
+ function validatePrepared(value) {
127
+ const prepared = record(value, "Prepared Subagent output");
128
+ const launch = record(prepared.launch, "Prepared Subagent launch");
129
+ if (!Array.isArray(launch.args))
130
+ throw new TypeError("Prepared Subagent launch args must be an array of strings.");
131
+ const args = [];
132
+ for (const [index, arg] of launch.args.entries()) {
133
+ if (typeof arg !== "string" || arg.includes("\0")) {
134
+ throw new TypeError(`Prepared Subagent launch arg ${index} must be a string without NUL bytes.`);
135
+ }
136
+ args.push(arg);
137
+ }
138
+ const launchEnv = record(launch.env, "Prepared Subagent launch env");
139
+ const env = Object.create(null);
140
+ for (const [name, value] of Object.entries(launchEnv)) {
141
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
142
+ throw new TypeError(`Invalid prepared launch environment name: ${name}`);
143
+ if (typeof value !== "string" || value.includes("\0")) {
144
+ throw new TypeError(`Invalid prepared launch environment value: ${name}`);
145
+ }
146
+ env[name] = value;
147
+ }
148
+ return {
149
+ launch: { args, env },
150
+ task: preparedText(prepared.task, "Prepared Subagent task"),
151
+ cwd: preparedText(prepared.cwd, "Prepared Subagent cwd"),
152
+ };
153
+ }
154
+ /**
155
+ * Creates a bounded ephemeral executor for callers already running inside Pi.
156
+ * It reuses the active Pi process invocation; it does not resolve a standalone Pi installation.
157
+ */
158
+ export function createEphemeralSubagentExecutor(options) {
159
+ const validated = validateOptions(options);
160
+ if (process.env.PI_CODING_AGENT !== "true" || (process.title !== "pi" && process.title !== "pi-rpc")) {
161
+ const cause = new Error("Ephemeral Subagent executor requires active Pi (PI_CODING_AGENT=true and process title pi or pi-rpc).");
162
+ throw new EphemeralSubagentError("prepare", cause.message, cause);
163
+ }
164
+ const invocation = piInvocation();
165
+ let active = 0;
166
+ const queue = [];
167
+ const acquire = (signal) => {
168
+ if (signal?.aborted)
169
+ return Promise.reject(abortError(signal));
170
+ if (active < validated.maxConcurrency) {
171
+ active += 1;
172
+ return Promise.resolve();
173
+ }
174
+ return new Promise((resolve, reject) => {
175
+ const abort = () => {
176
+ const index = queue.indexOf(grant);
177
+ if (index < 0)
178
+ return;
179
+ queue.splice(index, 1);
180
+ signal?.removeEventListener("abort", abort);
181
+ reject(abortError(signal));
182
+ };
183
+ const grant = () => {
184
+ signal?.removeEventListener("abort", abort);
185
+ resolve();
186
+ };
187
+ queue.push(grant);
188
+ signal?.addEventListener("abort", abort, { once: true });
189
+ });
190
+ };
191
+ const release = () => {
192
+ const grant = queue.shift();
193
+ if (grant)
194
+ grant();
195
+ else
196
+ active -= 1;
197
+ };
198
+ return {
199
+ async run(value) {
200
+ const input = validateRunInput(value);
201
+ await acquire(input.signal);
202
+ try {
203
+ if (input.signal?.aborted)
204
+ throw abortError(input.signal);
205
+ let prepared;
206
+ try {
207
+ prepared = validatePrepared(await input.prepare());
208
+ }
209
+ catch (cause) {
210
+ if (input.signal?.aborted)
211
+ throw abortError(input.signal, cause);
212
+ throw new EphemeralSubagentError("prepare", cause instanceof Error ? cause.message : String(cause), cause);
213
+ }
214
+ return await runPi(prepared, input, validated, invocation);
215
+ }
216
+ finally {
217
+ release();
218
+ }
219
+ },
220
+ };
221
+ }
222
+ function piInvocation() {
223
+ const currentScript = process.argv[1];
224
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
225
+ if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
226
+ return { command: process.execPath, args: [currentScript] };
227
+ }
228
+ if (isBunVirtualScript)
229
+ return { command: process.execPath, args: [] };
230
+ const executable = basename(process.execPath).toLowerCase();
231
+ if (!/^(node|bun)(\.exe)?$/.test(executable))
232
+ return { command: process.execPath, args: [] };
233
+ const cause = new Error("Ephemeral Subagent executor cannot reuse the active Pi process invocation.");
234
+ throw new EphemeralSubagentError("prepare", cause.message, cause);
235
+ }
236
+ function assistantText(message) {
237
+ if (!message || typeof message !== "object" || Array.isArray(message))
238
+ return;
239
+ const record = message;
240
+ if (record.role !== "assistant" || !Array.isArray(record.content))
241
+ return;
242
+ const text = record.content
243
+ .filter((part) => Boolean(part && typeof part === "object" && !Array.isArray(part)
244
+ && part.type === "text"
245
+ && typeof part.text === "string"))
246
+ .map((part) => part.text)
247
+ .join("\n");
248
+ return text || undefined;
249
+ }
250
+ function activityTooLong(value) {
251
+ return typeof value === "string" && Buffer.byteLength(value, "utf8") > MAX_ACTIVITY_TEXT_BYTES;
252
+ }
253
+ function hasTerminalControlChars(text) {
254
+ return hasDisplayControlCharacters(text) || /[\u2028\u2029]/u.test(text);
255
+ }
256
+ function activityText(value) {
257
+ return typeof value === "string" && value.trim().length > 0 && !activityTooLong(value) && !hasTerminalControlChars(value);
258
+ }
259
+ function oversizedToolStart(prefix) {
260
+ const match = JSON_OVERSIZED_TOOL_START.exec(prefix);
261
+ if (!match)
262
+ return;
263
+ try {
264
+ const toolCallId = JSON.parse(match[1]);
265
+ const toolName = JSON.parse(match[2]);
266
+ if (!activityText(toolCallId) || !activityText(toolName))
267
+ return;
268
+ return { type: "tool_execution_start", toolCallId, toolName };
269
+ }
270
+ catch {
271
+ return;
272
+ }
273
+ }
274
+ /** Longest prefix of `text` within `maxBytes` UTF-8 bytes, never splitting a code point. */
275
+ export function utf8Prefix(text, maxBytes) {
276
+ return new StringDecoder().write(Buffer.from(text).subarray(0, maxBytes));
277
+ }
278
+ function cappedPrefix(text, totalBytes) {
279
+ if (totalBytes <= MAX_OUTPUT_BYTES)
280
+ return text;
281
+ const worstCaseMarker = `\n\n[Output truncated: ${totalBytes} bytes omitted]`;
282
+ const prefix = utf8Prefix(text, MAX_OUTPUT_BYTES - Buffer.byteLength(worstCaseMarker, "utf8"));
283
+ const omittedBytes = totalBytes - Buffer.byteLength(prefix, "utf8");
284
+ return `${prefix}\n\n[Output truncated: ${omittedBytes} bytes omitted]`;
285
+ }
286
+ export function capEphemeralSubagentOutput(text) {
287
+ return cappedPrefix(text, Buffer.byteLength(text, "utf8"));
288
+ }
289
+ function appendBounded(target, text) {
290
+ target.totalBytes += Buffer.byteLength(text, "utf8");
291
+ const remaining = MAX_OUTPUT_BYTES - Buffer.byteLength(target.prefix, "utf8");
292
+ if (remaining > 0)
293
+ target.prefix += utf8Prefix(text, remaining);
294
+ }
295
+ function boundedText(target) {
296
+ return cappedPrefix(target.prefix, target.totalBytes);
297
+ }
298
+ function usageTokens(value) {
299
+ if (!value || typeof value !== "object" || Array.isArray(value))
300
+ return;
301
+ const total = value.totalTokens;
302
+ return typeof total === "number" && Number.isFinite(total) && total >= 0 ? Math.round(total) : undefined;
303
+ }
304
+ function nonNegativeNumber(value) {
305
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
306
+ }
307
+ function usageFrom(value) {
308
+ if (!value || typeof value !== "object" || Array.isArray(value))
309
+ return;
310
+ const record = value;
311
+ if (![record.input, record.output, record.cacheRead, record.cacheWrite, record.totalTokens].every(nonNegativeNumber))
312
+ return;
313
+ if (!record.cost || typeof record.cost !== "object" || Array.isArray(record.cost))
314
+ return;
315
+ const cost = record.cost;
316
+ if (![cost.input, cost.output, cost.cacheRead, cost.cacheWrite, cost.total].every(nonNegativeNumber))
317
+ return;
318
+ if (record.cacheWrite1h !== undefined && !nonNegativeNumber(record.cacheWrite1h))
319
+ return;
320
+ if (record.reasoning !== undefined && !nonNegativeNumber(record.reasoning))
321
+ return;
322
+ return {
323
+ input: record.input,
324
+ output: record.output,
325
+ cacheRead: record.cacheRead,
326
+ cacheWrite: record.cacheWrite,
327
+ ...(record.cacheWrite1h === undefined ? {} : { cacheWrite1h: record.cacheWrite1h }),
328
+ ...(record.reasoning === undefined ? {} : { reasoning: record.reasoning }),
329
+ totalTokens: record.totalTokens,
330
+ cost: {
331
+ input: cost.input,
332
+ output: cost.output,
333
+ cacheRead: cost.cacheRead,
334
+ cacheWrite: cost.cacheWrite,
335
+ total: cost.total,
336
+ },
337
+ };
338
+ }
339
+ function sumOptional(left, right) {
340
+ return left === undefined && right === undefined ? undefined : (left ?? 0) + (right ?? 0);
341
+ }
342
+ export function addUsage(left, right) {
343
+ if (!left)
344
+ return right;
345
+ if (!right)
346
+ return left;
347
+ const cacheWrite1h = sumOptional(left.cacheWrite1h, right.cacheWrite1h);
348
+ const reasoning = sumOptional(left.reasoning, right.reasoning);
349
+ return {
350
+ input: left.input + right.input,
351
+ output: left.output + right.output,
352
+ cacheRead: left.cacheRead + right.cacheRead,
353
+ cacheWrite: left.cacheWrite + right.cacheWrite,
354
+ ...(cacheWrite1h === undefined ? {} : { cacheWrite1h }),
355
+ ...(reasoning === undefined ? {} : { reasoning }),
356
+ totalTokens: left.totalTokens + right.totalTokens,
357
+ cost: {
358
+ input: left.cost.input + right.cost.input,
359
+ output: left.cost.output + right.cost.output,
360
+ cacheRead: left.cost.cacheRead + right.cost.cacheRead,
361
+ cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
362
+ total: left.cost.total + right.cost.total,
363
+ },
364
+ };
365
+ }
366
+ export function formatDuration(milliseconds) {
367
+ const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
368
+ const hours = Math.floor(seconds / 3_600);
369
+ const minutes = Math.floor(seconds % 3_600 / 60);
370
+ return hours ? `${hours}h ${minutes}m` : minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
371
+ }
372
+ async function runPi(prepared, input, budget, invocation) {
373
+ if (input.signal?.aborted)
374
+ throw abortError(input.signal);
375
+ const timeoutPolicy = budget.timeout;
376
+ return await new Promise((resolve, reject) => {
377
+ const args = [...invocation.args, "--mode", "json", "-p", ...prepared.launch.args, `Task: ${prepared.task}`];
378
+ const startedAt = Date.now();
379
+ const maxDeadline = timeoutPolicy.maxMs === null ? undefined : startedAt + timeoutPolicy.maxMs;
380
+ let child;
381
+ try {
382
+ const executionBudget = {
383
+ maxTurns: budget.maxTurns,
384
+ maxMs: timeoutPolicy.maxMs,
385
+ startedAt,
386
+ ...(budget.maxTokens === undefined ? {} : { maxTokens: budget.maxTokens }),
387
+ };
388
+ child = spawn(invocation.command, args, {
389
+ cwd: prepared.cwd,
390
+ env: {
391
+ ...process.env,
392
+ ...prepared.launch.env,
393
+ [EXECUTION_BUDGET_ENV]: JSON.stringify(executionBudget),
394
+ },
395
+ shell: false,
396
+ stdio: ["ignore", "pipe", "pipe"],
397
+ detached: process.platform !== "win32",
398
+ });
399
+ }
400
+ catch (cause) {
401
+ reject(new EphemeralSubagentError("spawn", cause instanceof Error ? cause.message : String(cause), cause));
402
+ return;
403
+ }
404
+ child.stdout.setEncoding("utf8");
405
+ child.stderr.setEncoding("utf8");
406
+ let lineParts = [];
407
+ let lineBytes = 0;
408
+ let linePrefix = "";
409
+ let lineEventType;
410
+ let ignoreLine = false;
411
+ let output = "";
412
+ let outputTruncated = false;
413
+ const stderr = { prefix: "", totalBytes: 0 };
414
+ const partial = { prefix: "", totalBytes: 0 };
415
+ const updateOutput = (nextOutput) => {
416
+ output = nextOutput;
417
+ outputTruncated = partial.totalBytes > MAX_OUTPUT_BYTES;
418
+ };
419
+ let hasPartialText = false;
420
+ let stopReason;
421
+ let errorMessage;
422
+ let spawnError;
423
+ let protocolError;
424
+ let aborted = false;
425
+ let settlementAborted = false;
426
+ let limit;
427
+ let tokenBudget = "within";
428
+ let startedTurns = 0;
429
+ let lastEventAt = startedAt;
430
+ let deadline = Math.min(startedAt + timeoutPolicy.idleMs, maxDeadline ?? startedAt + timeoutPolicy.idleMs);
431
+ let timedOutAfterMs;
432
+ let timeoutReason;
433
+ let childExited = false;
434
+ let completedTokens = 0;
435
+ let currentTokens = 0;
436
+ let completedUsage;
437
+ let currentUsage;
438
+ const accumulatedUsage = () => addUsage(completedUsage, currentUsage);
439
+ let deadlineTimer;
440
+ let killTimer;
441
+ let postExitIdleTimer;
442
+ let postExitHardTimer;
443
+ let callbackDrainTimer;
444
+ let stdoutEnded = false;
445
+ let stderrEnded = false;
446
+ let settled = false;
447
+ let cleaned = false;
448
+ let callbackFailure;
449
+ const pendingCallbacks = new Set();
450
+ let signalCallbackFailure;
451
+ const callbackFailed = new Promise((resolve) => { signalCallbackFailure = resolve; });
452
+ let onStdoutData;
453
+ let onStderrData;
454
+ let onStdoutEnd;
455
+ let onStderrEnd;
456
+ let onStdoutClose;
457
+ let onStderrClose;
458
+ let onChildError;
459
+ let onChildExit;
460
+ let onChildClose;
461
+ const clearTimer = (timer) => {
462
+ if (timer)
463
+ clearTimeout(timer);
464
+ };
465
+ const clearLifecycle = () => {
466
+ if (cleaned)
467
+ return;
468
+ cleaned = true;
469
+ clearTimer(deadlineTimer);
470
+ clearTimer(killTimer);
471
+ clearTimer(postExitIdleTimer);
472
+ clearTimer(postExitHardTimer);
473
+ clearTimer(callbackDrainTimer);
474
+ input.signal?.removeEventListener("abort", abort);
475
+ if (onStdoutData)
476
+ child.stdout.off("data", onStdoutData);
477
+ if (onStderrData)
478
+ child.stderr.off("data", onStderrData);
479
+ if (onStdoutEnd)
480
+ child.stdout.off("end", onStdoutEnd);
481
+ if (onStderrEnd)
482
+ child.stderr.off("end", onStderrEnd);
483
+ if (onStdoutClose)
484
+ child.stdout.off("close", onStdoutClose);
485
+ if (onStderrClose)
486
+ child.stderr.off("close", onStderrClose);
487
+ if (onChildError)
488
+ child.off("error", onChildError);
489
+ if (onChildExit)
490
+ child.off("exit", onChildExit);
491
+ if (onChildClose)
492
+ child.off("close", onChildClose);
493
+ };
494
+ const complete = (code) => {
495
+ if (settled)
496
+ return;
497
+ settled = true;
498
+ clearLifecycle();
499
+ if (callbackFailure) {
500
+ reject(new EphemeralSubagentError("callback", callbackFailure.message, callbackFailure.cause, accumulatedUsage()));
501
+ }
502
+ else if (aborted)
503
+ reject(abortError(input.signal, input.signal?.reason, accumulatedUsage()));
504
+ else if (timedOutAfterMs !== undefined) {
505
+ const message = timeoutReason === "maximum"
506
+ ? `Subagent reached its maximum runtime after ${formatDuration(timedOutAfterMs)}.`
507
+ : `Subagent timed out after ${formatDuration(timeoutPolicy.idleMs)} without a recognized Pi event.`;
508
+ reject(new EphemeralSubagentError("timeout", message, new Error(message), accumulatedUsage()));
509
+ }
510
+ else if (limit) {
511
+ const summary = limit === "turn_limit"
512
+ ? `Subagent reached its maximum turn limit of ${budget.maxTurns}.`
513
+ : `Subagent reached its maximum token limit of ${budget.maxTokens}.`;
514
+ const message = output ? capEphemeralSubagentOutput(`${summary}\n\nLast assistant output:\n${output}`) : summary;
515
+ reject(new EphemeralSubagentError(limit, message, new Error(message), accumulatedUsage(), output));
516
+ }
517
+ else if (protocolError) {
518
+ reject(new EphemeralSubagentError("protocol", protocolError.message, protocolError, accumulatedUsage()));
519
+ }
520
+ else if (spawnError) {
521
+ reject(new EphemeralSubagentError("spawn", spawnError.message, spawnError));
522
+ }
523
+ else {
524
+ const exitCode = code ?? 1;
525
+ if (settlementAborted)
526
+ stopReason = "aborted";
527
+ const outcome = exitCode !== 0 || stopReason === "error" || stopReason === "aborted" ? "failure" : "success";
528
+ resolve({
529
+ outcome,
530
+ exitCode,
531
+ output,
532
+ outputTruncated,
533
+ stderr: boundedText(stderr),
534
+ stopReason,
535
+ errorMessage,
536
+ usage: addUsage(completedUsage, currentUsage),
537
+ });
538
+ }
539
+ };
540
+ const failCallback = (name, cause) => {
541
+ if (callbackFailure)
542
+ return;
543
+ callbackFailure = new EphemeralSubagentError("callback", `Subagent ${name} callback failed.`, cause);
544
+ signalCallbackFailure();
545
+ stop(true);
546
+ };
547
+ let activityQueue = Promise.resolve();
548
+ const invokeCallback = (name, callback, value) => {
549
+ if (!callback || callbackFailure)
550
+ return;
551
+ let pending;
552
+ if (name === "onActivity") {
553
+ pending = activityQueue.then(() => {
554
+ if (!callbackFailure)
555
+ return callback(value);
556
+ }).catch((cause) => { failCallback(name, cause); });
557
+ activityQueue = pending;
558
+ }
559
+ else {
560
+ try {
561
+ pending = Promise.resolve(callback(value)).then(undefined, (cause) => { failCallback(name, cause); });
562
+ }
563
+ catch (cause) {
564
+ failCallback(name, cause);
565
+ return;
566
+ }
567
+ }
568
+ pendingCallbacks.add(pending);
569
+ void pending.then(() => pendingCallbacks.delete(pending));
570
+ };
571
+ const scheduleDeadline = () => {
572
+ if (deadlineTimer)
573
+ clearTimeout(deadlineTimer);
574
+ deadline = Math.min(lastEventAt + timeoutPolicy.idleMs, maxDeadline ?? lastEventAt + timeoutPolicy.idleMs);
575
+ const scheduledDeadline = deadline;
576
+ deadlineTimer = setTimeout(() => timeout(scheduledDeadline - startedAt, scheduledDeadline === maxDeadline ? "maximum" : "idle"), Math.max(0, scheduledDeadline - Date.now()));
577
+ deadlineTimer.unref();
578
+ };
579
+ const observeEvent = () => {
580
+ if (callbackFailure || aborted || timedOutAfterMs !== undefined || limit || childExited)
581
+ return;
582
+ const now = Date.now();
583
+ if (now >= deadline) {
584
+ timeout(deadline - startedAt, deadline === maxDeadline ? "maximum" : "idle");
585
+ return;
586
+ }
587
+ lastEventAt = now;
588
+ scheduleDeadline();
589
+ };
590
+ const advanceTokenBudget = (event) => {
591
+ switch (tokenBudget) {
592
+ case "within":
593
+ if (event === "turn_end" && budget.maxTokens !== undefined && completedTokens >= budget.maxTokens) {
594
+ tokenBudget = "crossed";
595
+ }
596
+ return false;
597
+ case "crossed":
598
+ if (event === "turn_start")
599
+ tokenBudget = "final_turn";
600
+ return false;
601
+ case "final_turn":
602
+ if (event === "turn_start") {
603
+ if (!callbackFailure && !aborted && timedOutAfterMs === undefined)
604
+ limit = "token_limit";
605
+ stop(true);
606
+ return true;
607
+ }
608
+ return false;
609
+ }
610
+ };
611
+ const processLine = (line) => {
612
+ if (limit || !line.trim())
613
+ return;
614
+ let event;
615
+ try {
616
+ event = JSON.parse(line);
617
+ }
618
+ catch {
619
+ return;
620
+ }
621
+ if (!event || typeof event !== "object" || Array.isArray(event))
622
+ return;
623
+ const record = event;
624
+ if (typeof record.type !== "string" || !Object.hasOwn(PI_JSON_EVENTS, record.type))
625
+ return;
626
+ if (record.type === "agent_settled") {
627
+ if (typeof record.aborted !== "boolean") {
628
+ protocolError = new Error("Subagent agent_settled.aborted must be a boolean.");
629
+ stop(true);
630
+ return;
631
+ }
632
+ settlementAborted ||= record.aborted;
633
+ }
634
+ observeEvent();
635
+ if (record.type === "turn_start") {
636
+ if (++startedTurns > budget.maxTurns) {
637
+ if (!callbackFailure && !aborted && timedOutAfterMs === undefined)
638
+ limit = "turn_limit";
639
+ stop(true);
640
+ return;
641
+ }
642
+ if (advanceTokenBudget("turn_start"))
643
+ return;
644
+ }
645
+ if (record.type === "turn_end")
646
+ advanceTokenBudget("turn_end");
647
+ if (record.type === "message_start") {
648
+ partial.prefix = "";
649
+ partial.totalBytes = 0;
650
+ hasPartialText = false;
651
+ currentUsage = undefined;
652
+ return;
653
+ }
654
+ if (record.type === "message_update") {
655
+ const tokens = usageTokens(record.usage);
656
+ if (tokens !== undefined) {
657
+ currentTokens = tokens;
658
+ invokeCallback("onTokens", input.onTokens, completedTokens + currentTokens);
659
+ }
660
+ currentUsage = usageFrom(record.usage) ?? currentUsage;
661
+ const update = record.assistantMessageEvent;
662
+ if (update && typeof update === "object" && !Array.isArray(update)) {
663
+ const assistantEvent = update;
664
+ if (assistantEvent.type === "text_start" && hasPartialText) {
665
+ appendBounded(partial, "\n");
666
+ updateOutput(boundedText(partial));
667
+ }
668
+ if (assistantEvent.type === "text_start")
669
+ hasPartialText = true;
670
+ if (assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string") {
671
+ hasPartialText = true;
672
+ appendBounded(partial, assistantEvent.delta);
673
+ updateOutput(boundedText(partial));
674
+ invokeCallback("onUpdate", input.onUpdate, output);
675
+ }
676
+ }
677
+ return;
678
+ }
679
+ if (record.type === "tool_execution_start" || record.type === "tool_execution_end") {
680
+ const { toolCallId, toolName } = record;
681
+ if (!activityText(toolCallId) || !activityText(toolName))
682
+ return;
683
+ if (record.type === "tool_execution_start") {
684
+ const args = record.args;
685
+ const path = args && typeof args === "object" && !Array.isArray(args)
686
+ ? args.path
687
+ : undefined;
688
+ if (activityTooLong(path))
689
+ return;
690
+ invokeCallback("onActivity", input.onActivity, {
691
+ type: "tool_execution_start",
692
+ toolCallId,
693
+ toolName,
694
+ ...(activityText(path) ? { path } : {}),
695
+ });
696
+ }
697
+ else {
698
+ invokeCallback("onActivity", input.onActivity, { type: "tool_execution_end", toolCallId, toolName });
699
+ }
700
+ return;
701
+ }
702
+ if (record.type !== "message_end")
703
+ return;
704
+ const text = assistantText(record.message);
705
+ if (text !== undefined) {
706
+ partial.prefix = "";
707
+ partial.totalBytes = 0;
708
+ appendBounded(partial, text);
709
+ updateOutput(capEphemeralSubagentOutput(text));
710
+ invokeCallback("onUpdate", input.onUpdate, output);
711
+ }
712
+ if (record.message && typeof record.message === "object" && !Array.isArray(record.message)) {
713
+ const message = record.message;
714
+ if (message.role === "assistant") {
715
+ const finalUsage = usageFrom(message.usage) ?? currentUsage;
716
+ completedUsage = addUsage(completedUsage, finalUsage);
717
+ completedTokens += usageTokens(message.usage) ?? currentTokens;
718
+ currentTokens = 0;
719
+ currentUsage = undefined;
720
+ invokeCallback("onTokens", input.onTokens, completedTokens);
721
+ invokeCallback("onActivity", input.onActivity, { type: "message_end" });
722
+ }
723
+ if (typeof message.stopReason === "string")
724
+ stopReason = message.stopReason;
725
+ if (typeof message.errorMessage === "string")
726
+ errorMessage = message.errorMessage;
727
+ }
728
+ };
729
+ async function killTree(force) {
730
+ if (!child.pid)
731
+ return;
732
+ if (process.platform === "win32") {
733
+ await new Promise((done) => {
734
+ const taskkill = spawn("taskkill", [...(force ? ["/F"] : []), "/T", "/PID", String(child.pid)], {
735
+ stdio: "ignore",
736
+ windowsHide: true,
737
+ });
738
+ taskkill.once("error", () => done());
739
+ taskkill.once("close", () => done());
740
+ });
741
+ return;
742
+ }
743
+ try {
744
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
745
+ }
746
+ catch {
747
+ child.kill(force ? "SIGKILL" : "SIGTERM");
748
+ }
749
+ }
750
+ // `close` waits for stdio EOF, which an escaped descendant can retain after Pi exits.
751
+ const clearPostExitTimers = () => {
752
+ clearTimer(postExitIdleTimer);
753
+ clearTimer(postExitHardTimer);
754
+ postExitIdleTimer = undefined;
755
+ postExitHardTimer = undefined;
756
+ };
757
+ const destroyOpenStdio = () => {
758
+ if (!stdoutEnded)
759
+ child.stdout.destroy();
760
+ if (!stderrEnded)
761
+ child.stderr.destroy();
762
+ };
763
+ const armPostExitIdleDeadline = () => {
764
+ if (!childExited || (stdoutEnded && stderrEnded))
765
+ return;
766
+ clearTimer(postExitIdleTimer);
767
+ postExitIdleTimer = setTimeout(destroyOpenStdio, POST_EXIT_STDIO_IDLE_MS);
768
+ postExitIdleTimer.unref();
769
+ };
770
+ const endStdio = () => {
771
+ if (stdoutEnded && stderrEnded)
772
+ clearPostExitTimers();
773
+ };
774
+ onStdoutData = (data) => {
775
+ armPostExitIdleDeadline();
776
+ if (callbackFailure || protocolError || limit)
777
+ return;
778
+ let offset = 0;
779
+ while (offset < data.length) {
780
+ const newline = data.indexOf("\n", offset);
781
+ const end = newline === -1 ? data.length : newline;
782
+ const part = data.slice(offset, end);
783
+ if (!ignoreLine) {
784
+ const remainingPrefix = MAX_ACTIVITY_PREFIX_BYTES - Buffer.byteLength(linePrefix, "utf8");
785
+ if (remainingPrefix > 0)
786
+ linePrefix += utf8Prefix(part, remainingPrefix);
787
+ const eventType = JSON_EVENT_TYPE.exec(linePrefix)?.[1];
788
+ if (eventType && !lineEventType)
789
+ lineEventType = eventType;
790
+ lineBytes += Buffer.byteLength(part, "utf8");
791
+ if (lineBytes > MAX_JSON_EVENT_BYTES) {
792
+ if (lineEventType && !CONSUMED_JSON_EVENTS.has(lineEventType)) {
793
+ ignoreLine = true;
794
+ lineParts = [];
795
+ lineBytes = 0;
796
+ }
797
+ else {
798
+ protocolError = new Error(`Subagent JSON event exceeds ${MAX_JSON_EVENT_BYTES} bytes.`);
799
+ void killTree(true);
800
+ return;
801
+ }
802
+ }
803
+ else if (part)
804
+ lineParts.push(part);
805
+ }
806
+ if (newline === -1)
807
+ return;
808
+ if (!ignoreLine)
809
+ processLine(lineParts.join(""));
810
+ else if (lineEventType === "tool_execution_start") {
811
+ const activity = oversizedToolStart(linePrefix);
812
+ if (activity) {
813
+ observeEvent();
814
+ invokeCallback("onActivity", input.onActivity, activity);
815
+ }
816
+ }
817
+ if (callbackFailure || limit)
818
+ return;
819
+ lineParts = [];
820
+ lineBytes = 0;
821
+ linePrefix = "";
822
+ lineEventType = undefined;
823
+ ignoreLine = false;
824
+ offset = newline + 1;
825
+ }
826
+ };
827
+ onStderrData = (data) => {
828
+ armPostExitIdleDeadline();
829
+ appendBounded(stderr, data);
830
+ };
831
+ onStdoutEnd = () => {
832
+ stdoutEnded = true;
833
+ endStdio();
834
+ };
835
+ onStderrEnd = () => {
836
+ stderrEnded = true;
837
+ endStdio();
838
+ };
839
+ onStdoutClose = onStdoutEnd;
840
+ onStderrClose = onStderrEnd;
841
+ child.stdout.on("data", onStdoutData);
842
+ child.stderr.on("data", onStderrData);
843
+ child.stdout.on("end", onStdoutEnd);
844
+ child.stderr.on("end", onStderrEnd);
845
+ child.stdout.on("close", onStdoutClose);
846
+ child.stderr.on("close", onStderrClose);
847
+ function stop(force = false) {
848
+ if (force) {
849
+ void killTree(true);
850
+ return;
851
+ }
852
+ void killTree(false);
853
+ killTimer = setTimeout(() => void killTree(true), maxDeadline === undefined ? 5_000 : Math.min(5_000, Math.max(0, maxDeadline - Date.now())));
854
+ killTimer.unref();
855
+ }
856
+ const abort = () => {
857
+ if (timedOutAfterMs !== undefined || limit || childExited)
858
+ return;
859
+ aborted = true;
860
+ stop();
861
+ };
862
+ function timeout(afterMs, reason) {
863
+ if (timedOutAfterMs !== undefined || limit || childExited)
864
+ return;
865
+ if (reason === "maximum") {
866
+ if (!aborted) {
867
+ timedOutAfterMs = afterMs;
868
+ timeoutReason = reason;
869
+ }
870
+ stop(true);
871
+ return;
872
+ }
873
+ if (aborted)
874
+ return;
875
+ timedOutAfterMs = afterMs;
876
+ timeoutReason = reason;
877
+ stop();
878
+ }
879
+ onChildError = (error) => {
880
+ spawnError = error;
881
+ if (input.signal?.aborted)
882
+ aborted = true;
883
+ };
884
+ onChildExit = () => {
885
+ childExited = true;
886
+ clearTimer(deadlineTimer);
887
+ clearTimer(killTimer);
888
+ void killTree(true);
889
+ armPostExitIdleDeadline();
890
+ postExitHardTimer = setTimeout(destroyOpenStdio, POST_EXIT_STDIO_HARD_MS);
891
+ postExitHardTimer.unref();
892
+ };
893
+ onChildClose = async (code) => {
894
+ if (settled)
895
+ return;
896
+ if (!callbackFailure && !protocolError && lineBytes)
897
+ processLine(lineParts.join(""));
898
+ await killTree(true);
899
+ // A caller callback that never settles must not hold `run()` or its permit
900
+ // past child exit; bound the drain by what remains of the maximum runtime.
901
+ callbackDrainTimer = setTimeout(() => {
902
+ callbackFailure ??= new EphemeralSubagentError("callback", "Subagent callback did not settle before the post-exit drain deadline.");
903
+ signalCallbackFailure();
904
+ }, maxDeadline === undefined ? 5_000 : Math.min(5_000, Math.max(0, maxDeadline - Date.now())));
905
+ await Promise.race([Promise.all(pendingCallbacks), callbackFailed]);
906
+ complete(code);
907
+ };
908
+ child.once("error", onChildError);
909
+ child.once("exit", onChildExit);
910
+ child.once("close", onChildClose);
911
+ scheduleDeadline();
912
+ input.signal?.addEventListener("abort", abort, { once: true });
913
+ if (input.signal?.aborted)
914
+ abort();
915
+ });
916
+ }