@henryqw/pi-subagent 3.0.3 → 3.1.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 +8 -4
- package/README.md +54 -62
- package/dist/ephemeral.d.ts +50 -0
- package/dist/ephemeral.js +651 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +10 -2
- package/docs/adr/001-composable-ephemeral-execution.md +19 -0
- package/docs/orchestration.md +342 -0
- package/examples/roles/implementer.md +14 -0
- package/examples/roles/reviewer.md +11 -0
- package/examples/roles/scout.md +17 -0
- package/examples/roles/synthesizer.md +18 -0
- package/extensions/result-transport.ts +213 -0
- package/extensions/subagent.ts +366 -573
- package/extensions/workflow.ts +202 -0
- package/package.json +4 -2
|
@@ -0,0 +1,651 @@
|
|
|
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
|
+
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
6
|
+
const MAX_JSON_EVENT_BYTES = 1024 * 1024;
|
|
7
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
8
|
+
const PI_JSON_EVENTS = {
|
|
9
|
+
agent_start: true,
|
|
10
|
+
agent_end: true,
|
|
11
|
+
agent_settled: true,
|
|
12
|
+
turn_start: true,
|
|
13
|
+
turn_end: true,
|
|
14
|
+
message_start: true,
|
|
15
|
+
message_update: true,
|
|
16
|
+
message_end: true,
|
|
17
|
+
tool_execution_start: true,
|
|
18
|
+
tool_execution_update: true,
|
|
19
|
+
tool_execution_end: true,
|
|
20
|
+
queue_update: true,
|
|
21
|
+
compaction_start: true,
|
|
22
|
+
compaction_end: true,
|
|
23
|
+
entry_appended: true,
|
|
24
|
+
session_info_changed: true,
|
|
25
|
+
thinking_level_changed: true,
|
|
26
|
+
auto_retry_start: true,
|
|
27
|
+
auto_retry_end: true,
|
|
28
|
+
summarization_retry_scheduled: true,
|
|
29
|
+
summarization_retry_attempt_start: true,
|
|
30
|
+
summarization_retry_finished: true,
|
|
31
|
+
bash_execution_update: true,
|
|
32
|
+
};
|
|
33
|
+
const CONSUMED_JSON_EVENTS = new Set(["message_start", "message_update", "message_end"]);
|
|
34
|
+
const JSON_EVENT_TYPE = /^\s*\{\s*"type"\s*:\s*"([^"\\]+)"/;
|
|
35
|
+
export class EphemeralSubagentError extends Error {
|
|
36
|
+
name = "EphemeralSubagentError";
|
|
37
|
+
code;
|
|
38
|
+
usage;
|
|
39
|
+
constructor(code, message, cause, usage) {
|
|
40
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.usage = usage;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function positiveDelay(value, field) {
|
|
46
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) {
|
|
47
|
+
throw new RangeError(`${field} must be a positive number no greater than ${MAX_TIMER_DELAY_MS}.`);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function validateOptions(options) {
|
|
52
|
+
if (!options || typeof options !== "object")
|
|
53
|
+
throw new TypeError("Ephemeral Subagent executor options are required.");
|
|
54
|
+
if (!Number.isSafeInteger(options.maxConcurrency) || options.maxConcurrency < 1) {
|
|
55
|
+
throw new RangeError("maxConcurrency must be a positive safe integer.");
|
|
56
|
+
}
|
|
57
|
+
if (!options.timeout || typeof options.timeout !== "object")
|
|
58
|
+
throw new TypeError("timeout is required.");
|
|
59
|
+
const timeout = {
|
|
60
|
+
idleMs: positiveDelay(options.timeout.idleMs, "timeout.idleMs"),
|
|
61
|
+
maxMs: positiveDelay(options.timeout.maxMs, "timeout.maxMs"),
|
|
62
|
+
};
|
|
63
|
+
if (timeout.maxMs <= timeout.idleMs)
|
|
64
|
+
throw new RangeError("timeout.maxMs must be greater than timeout.idleMs.");
|
|
65
|
+
return { maxConcurrency: options.maxConcurrency, timeout };
|
|
66
|
+
}
|
|
67
|
+
function abortError(signal, cause = signal?.reason, usage) {
|
|
68
|
+
return new EphemeralSubagentError("aborted", "Subagent was aborted.", cause, usage);
|
|
69
|
+
}
|
|
70
|
+
function validateRunInput(value) {
|
|
71
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
72
|
+
throw new TypeError("Ephemeral Subagent run input must be an object.");
|
|
73
|
+
}
|
|
74
|
+
const input = value;
|
|
75
|
+
if (typeof input.prepare !== "function")
|
|
76
|
+
throw new TypeError("run.prepare must be a function.");
|
|
77
|
+
if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) {
|
|
78
|
+
throw new TypeError("run.signal must be an AbortSignal.");
|
|
79
|
+
}
|
|
80
|
+
if (input.onUpdate !== undefined && typeof input.onUpdate !== "function") {
|
|
81
|
+
throw new TypeError("run.onUpdate must be a function.");
|
|
82
|
+
}
|
|
83
|
+
if (input.onTokens !== undefined && typeof input.onTokens !== "function") {
|
|
84
|
+
throw new TypeError("run.onTokens must be a function.");
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
signal: input.signal,
|
|
88
|
+
prepare: input.prepare,
|
|
89
|
+
onUpdate: input.onUpdate,
|
|
90
|
+
onTokens: input.onTokens,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function record(value, field) {
|
|
94
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
95
|
+
throw new TypeError(`${field} must be an object.`);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function preparedText(value, field) {
|
|
99
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
|
|
100
|
+
throw new TypeError(`${field} must be non-empty text without NUL bytes.`);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
function validatePrepared(value) {
|
|
105
|
+
const prepared = record(value, "Prepared Subagent output");
|
|
106
|
+
const launch = record(prepared.launch, "Prepared Subagent launch");
|
|
107
|
+
if (!Array.isArray(launch.args))
|
|
108
|
+
throw new TypeError("Prepared Subagent launch args must be an array of strings.");
|
|
109
|
+
const args = [];
|
|
110
|
+
for (const [index, arg] of launch.args.entries()) {
|
|
111
|
+
if (typeof arg !== "string" || arg.includes("\0")) {
|
|
112
|
+
throw new TypeError(`Prepared Subagent launch arg ${index} must be a string without NUL bytes.`);
|
|
113
|
+
}
|
|
114
|
+
args.push(arg);
|
|
115
|
+
}
|
|
116
|
+
const launchEnv = record(launch.env, "Prepared Subagent launch env");
|
|
117
|
+
const env = Object.create(null);
|
|
118
|
+
for (const [name, value] of Object.entries(launchEnv)) {
|
|
119
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
120
|
+
throw new TypeError(`Invalid prepared launch environment name: ${name}`);
|
|
121
|
+
if (typeof value !== "string" || value.includes("\0")) {
|
|
122
|
+
throw new TypeError(`Invalid prepared launch environment value: ${name}`);
|
|
123
|
+
}
|
|
124
|
+
env[name] = value;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
launch: { args, env },
|
|
128
|
+
task: preparedText(prepared.task, "Prepared Subagent task"),
|
|
129
|
+
cwd: preparedText(prepared.cwd, "Prepared Subagent cwd"),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Creates a bounded ephemeral executor for callers already running inside Pi.
|
|
134
|
+
* It reuses the active Pi process invocation; it does not resolve a standalone Pi installation.
|
|
135
|
+
*/
|
|
136
|
+
export function createEphemeralSubagentExecutor(options) {
|
|
137
|
+
const validated = validateOptions(options);
|
|
138
|
+
if (process.env.PI_CODING_AGENT !== "true" || (process.title !== "pi" && process.title !== "pi-rpc")) {
|
|
139
|
+
const cause = new Error("Ephemeral Subagent executor requires active Pi (PI_CODING_AGENT=true and process title pi or pi-rpc).");
|
|
140
|
+
throw new EphemeralSubagentError("prepare", cause.message, cause);
|
|
141
|
+
}
|
|
142
|
+
const invocation = piInvocation();
|
|
143
|
+
let active = 0;
|
|
144
|
+
const queue = [];
|
|
145
|
+
const acquire = (signal) => {
|
|
146
|
+
if (signal?.aborted)
|
|
147
|
+
return Promise.reject(abortError(signal));
|
|
148
|
+
if (active < validated.maxConcurrency) {
|
|
149
|
+
active += 1;
|
|
150
|
+
return Promise.resolve();
|
|
151
|
+
}
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const abort = () => {
|
|
154
|
+
const index = queue.indexOf(grant);
|
|
155
|
+
if (index < 0)
|
|
156
|
+
return;
|
|
157
|
+
queue.splice(index, 1);
|
|
158
|
+
signal?.removeEventListener("abort", abort);
|
|
159
|
+
reject(abortError(signal));
|
|
160
|
+
};
|
|
161
|
+
const grant = () => {
|
|
162
|
+
signal?.removeEventListener("abort", abort);
|
|
163
|
+
resolve();
|
|
164
|
+
};
|
|
165
|
+
queue.push(grant);
|
|
166
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
const release = () => {
|
|
170
|
+
const grant = queue.shift();
|
|
171
|
+
if (grant)
|
|
172
|
+
grant();
|
|
173
|
+
else
|
|
174
|
+
active -= 1;
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
async run(value) {
|
|
178
|
+
const input = validateRunInput(value);
|
|
179
|
+
await acquire(input.signal);
|
|
180
|
+
try {
|
|
181
|
+
if (input.signal?.aborted)
|
|
182
|
+
throw abortError(input.signal);
|
|
183
|
+
let prepared;
|
|
184
|
+
try {
|
|
185
|
+
prepared = validatePrepared(await input.prepare());
|
|
186
|
+
}
|
|
187
|
+
catch (cause) {
|
|
188
|
+
if (input.signal?.aborted)
|
|
189
|
+
throw abortError(input.signal, cause);
|
|
190
|
+
throw new EphemeralSubagentError("prepare", cause instanceof Error ? cause.message : String(cause), cause);
|
|
191
|
+
}
|
|
192
|
+
if (input.signal?.aborted)
|
|
193
|
+
throw abortError(input.signal);
|
|
194
|
+
return await runPi(prepared, input, validated.timeout, invocation);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
release();
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function piInvocation() {
|
|
203
|
+
const currentScript = process.argv[1];
|
|
204
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
205
|
+
if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
|
|
206
|
+
return { command: process.execPath, args: [currentScript] };
|
|
207
|
+
}
|
|
208
|
+
if (isBunVirtualScript)
|
|
209
|
+
return { command: process.execPath, args: [] };
|
|
210
|
+
const executable = basename(process.execPath).toLowerCase();
|
|
211
|
+
if (!/^(node|bun)(\.exe)?$/.test(executable))
|
|
212
|
+
return { command: process.execPath, args: [] };
|
|
213
|
+
const cause = new Error("Ephemeral Subagent executor cannot reuse the active Pi process invocation.");
|
|
214
|
+
throw new EphemeralSubagentError("prepare", cause.message, cause);
|
|
215
|
+
}
|
|
216
|
+
function assistantText(message) {
|
|
217
|
+
if (!message || typeof message !== "object" || Array.isArray(message))
|
|
218
|
+
return;
|
|
219
|
+
const record = message;
|
|
220
|
+
if (record.role !== "assistant" || !Array.isArray(record.content))
|
|
221
|
+
return;
|
|
222
|
+
const text = record.content
|
|
223
|
+
.filter((part) => Boolean(part && typeof part === "object" && !Array.isArray(part)
|
|
224
|
+
&& part.type === "text"
|
|
225
|
+
&& typeof part.text === "string"))
|
|
226
|
+
.map((part) => part.text)
|
|
227
|
+
.join("\n");
|
|
228
|
+
return text || undefined;
|
|
229
|
+
}
|
|
230
|
+
function utf8Prefix(text, maxBytes) {
|
|
231
|
+
return new StringDecoder().write(Buffer.from(text).subarray(0, maxBytes));
|
|
232
|
+
}
|
|
233
|
+
function cappedPrefix(text, totalBytes) {
|
|
234
|
+
if (totalBytes <= MAX_OUTPUT_BYTES)
|
|
235
|
+
return text;
|
|
236
|
+
const worstCaseMarker = `\n\n[Output truncated: ${totalBytes} bytes omitted]`;
|
|
237
|
+
const prefix = utf8Prefix(text, MAX_OUTPUT_BYTES - Buffer.byteLength(worstCaseMarker, "utf8"));
|
|
238
|
+
const omittedBytes = totalBytes - Buffer.byteLength(prefix, "utf8");
|
|
239
|
+
return `${prefix}\n\n[Output truncated: ${omittedBytes} bytes omitted]`;
|
|
240
|
+
}
|
|
241
|
+
export function capEphemeralSubagentOutput(text) {
|
|
242
|
+
return cappedPrefix(text, Buffer.byteLength(text, "utf8"));
|
|
243
|
+
}
|
|
244
|
+
function appendBounded(target, text) {
|
|
245
|
+
target.totalBytes += Buffer.byteLength(text, "utf8");
|
|
246
|
+
const remaining = MAX_OUTPUT_BYTES - Buffer.byteLength(target.prefix, "utf8");
|
|
247
|
+
if (remaining > 0)
|
|
248
|
+
target.prefix += utf8Prefix(text, remaining);
|
|
249
|
+
}
|
|
250
|
+
function boundedText(target) {
|
|
251
|
+
return cappedPrefix(target.prefix, target.totalBytes);
|
|
252
|
+
}
|
|
253
|
+
function usageTokens(value) {
|
|
254
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
255
|
+
return;
|
|
256
|
+
const total = value.totalTokens;
|
|
257
|
+
return typeof total === "number" && Number.isFinite(total) && total >= 0 ? Math.round(total) : undefined;
|
|
258
|
+
}
|
|
259
|
+
function nonNegativeNumber(value) {
|
|
260
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
261
|
+
}
|
|
262
|
+
function usageFrom(value) {
|
|
263
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
264
|
+
return;
|
|
265
|
+
const record = value;
|
|
266
|
+
if (![record.input, record.output, record.cacheRead, record.cacheWrite, record.totalTokens].every(nonNegativeNumber))
|
|
267
|
+
return;
|
|
268
|
+
if (!record.cost || typeof record.cost !== "object" || Array.isArray(record.cost))
|
|
269
|
+
return;
|
|
270
|
+
const cost = record.cost;
|
|
271
|
+
if (![cost.input, cost.output, cost.cacheRead, cost.cacheWrite, cost.total].every(nonNegativeNumber))
|
|
272
|
+
return;
|
|
273
|
+
if (record.cacheWrite1h !== undefined && !nonNegativeNumber(record.cacheWrite1h))
|
|
274
|
+
return;
|
|
275
|
+
if (record.reasoning !== undefined && !nonNegativeNumber(record.reasoning))
|
|
276
|
+
return;
|
|
277
|
+
return {
|
|
278
|
+
input: record.input,
|
|
279
|
+
output: record.output,
|
|
280
|
+
cacheRead: record.cacheRead,
|
|
281
|
+
cacheWrite: record.cacheWrite,
|
|
282
|
+
...(record.cacheWrite1h === undefined ? {} : { cacheWrite1h: record.cacheWrite1h }),
|
|
283
|
+
...(record.reasoning === undefined ? {} : { reasoning: record.reasoning }),
|
|
284
|
+
totalTokens: record.totalTokens,
|
|
285
|
+
cost: {
|
|
286
|
+
input: cost.input,
|
|
287
|
+
output: cost.output,
|
|
288
|
+
cacheRead: cost.cacheRead,
|
|
289
|
+
cacheWrite: cost.cacheWrite,
|
|
290
|
+
total: cost.total,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function sumOptional(left, right) {
|
|
295
|
+
return left === undefined && right === undefined ? undefined : (left ?? 0) + (right ?? 0);
|
|
296
|
+
}
|
|
297
|
+
function addUsage(left, right) {
|
|
298
|
+
if (!left)
|
|
299
|
+
return right;
|
|
300
|
+
if (!right)
|
|
301
|
+
return left;
|
|
302
|
+
const cacheWrite1h = sumOptional(left.cacheWrite1h, right.cacheWrite1h);
|
|
303
|
+
const reasoning = sumOptional(left.reasoning, right.reasoning);
|
|
304
|
+
return {
|
|
305
|
+
input: left.input + right.input,
|
|
306
|
+
output: left.output + right.output,
|
|
307
|
+
cacheRead: left.cacheRead + right.cacheRead,
|
|
308
|
+
cacheWrite: left.cacheWrite + right.cacheWrite,
|
|
309
|
+
...(cacheWrite1h === undefined ? {} : { cacheWrite1h }),
|
|
310
|
+
...(reasoning === undefined ? {} : { reasoning }),
|
|
311
|
+
totalTokens: left.totalTokens + right.totalTokens,
|
|
312
|
+
cost: {
|
|
313
|
+
input: left.cost.input + right.cost.input,
|
|
314
|
+
output: left.cost.output + right.cost.output,
|
|
315
|
+
cacheRead: left.cost.cacheRead + right.cost.cacheRead,
|
|
316
|
+
cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
|
|
317
|
+
total: left.cost.total + right.cost.total,
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function formatDuration(milliseconds) {
|
|
322
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
323
|
+
const hours = Math.floor(seconds / 3_600);
|
|
324
|
+
const minutes = Math.floor(seconds % 3_600 / 60);
|
|
325
|
+
return hours ? `${hours}h ${minutes}m` : minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
|
326
|
+
}
|
|
327
|
+
async function runPi(prepared, input, timeoutPolicy, invocation) {
|
|
328
|
+
if (input.signal?.aborted)
|
|
329
|
+
throw abortError(input.signal);
|
|
330
|
+
return await new Promise((resolve, reject) => {
|
|
331
|
+
const args = [...invocation.args, "--mode", "json", "-p", ...prepared.launch.args, `Task: ${prepared.task}`];
|
|
332
|
+
let child;
|
|
333
|
+
try {
|
|
334
|
+
child = spawn(invocation.command, args, {
|
|
335
|
+
cwd: prepared.cwd,
|
|
336
|
+
env: { ...process.env, ...prepared.launch.env },
|
|
337
|
+
shell: false,
|
|
338
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
339
|
+
detached: process.platform !== "win32",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
catch (cause) {
|
|
343
|
+
reject(new EphemeralSubagentError("spawn", cause instanceof Error ? cause.message : String(cause), cause));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
child.stdout.setEncoding("utf8");
|
|
347
|
+
child.stderr.setEncoding("utf8");
|
|
348
|
+
let lineParts = [];
|
|
349
|
+
let lineBytes = 0;
|
|
350
|
+
let linePrefix = "";
|
|
351
|
+
let lineEventType;
|
|
352
|
+
let ignoreLine = false;
|
|
353
|
+
let output = "";
|
|
354
|
+
const stderr = { prefix: "", totalBytes: 0 };
|
|
355
|
+
const partial = { prefix: "", totalBytes: 0 };
|
|
356
|
+
let hasPartialText = false;
|
|
357
|
+
let stopReason;
|
|
358
|
+
let errorMessage;
|
|
359
|
+
let spawnError;
|
|
360
|
+
let protocolError;
|
|
361
|
+
let aborted = false;
|
|
362
|
+
const startedAt = Date.now();
|
|
363
|
+
const maxDeadline = startedAt + timeoutPolicy.maxMs;
|
|
364
|
+
let lastEventAt = startedAt;
|
|
365
|
+
let deadline = Math.min(startedAt + timeoutPolicy.idleMs, maxDeadline);
|
|
366
|
+
let timedOutAfterMs;
|
|
367
|
+
let timeoutReason;
|
|
368
|
+
let childExited = false;
|
|
369
|
+
let completedTokens = 0;
|
|
370
|
+
let currentTokens = 0;
|
|
371
|
+
let completedUsage;
|
|
372
|
+
let currentUsage;
|
|
373
|
+
const accumulatedUsage = () => addUsage(completedUsage, currentUsage);
|
|
374
|
+
let deadlineTimer;
|
|
375
|
+
let killTimer;
|
|
376
|
+
let callbackFailure;
|
|
377
|
+
const pendingCallbacks = new Set();
|
|
378
|
+
let signalCallbackFailure;
|
|
379
|
+
const callbackFailed = new Promise((resolve) => { signalCallbackFailure = resolve; });
|
|
380
|
+
const failCallback = (name, cause) => {
|
|
381
|
+
if (callbackFailure)
|
|
382
|
+
return;
|
|
383
|
+
callbackFailure = new EphemeralSubagentError("callback", `Subagent ${name} callback failed.`, cause);
|
|
384
|
+
signalCallbackFailure();
|
|
385
|
+
stop(true);
|
|
386
|
+
};
|
|
387
|
+
const invokeCallback = (name, callback, value) => {
|
|
388
|
+
if (!callback || callbackFailure)
|
|
389
|
+
return;
|
|
390
|
+
let pending;
|
|
391
|
+
try {
|
|
392
|
+
pending = Promise.resolve(callback(value)).then(undefined, (cause) => { failCallback(name, cause); });
|
|
393
|
+
}
|
|
394
|
+
catch (cause) {
|
|
395
|
+
failCallback(name, cause);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
pendingCallbacks.add(pending);
|
|
399
|
+
void pending.then(() => pendingCallbacks.delete(pending));
|
|
400
|
+
};
|
|
401
|
+
const scheduleDeadline = () => {
|
|
402
|
+
if (deadlineTimer)
|
|
403
|
+
clearTimeout(deadlineTimer);
|
|
404
|
+
deadline = Math.min(lastEventAt + timeoutPolicy.idleMs, maxDeadline);
|
|
405
|
+
const scheduledDeadline = deadline;
|
|
406
|
+
deadlineTimer = setTimeout(() => timeout(scheduledDeadline - startedAt, scheduledDeadline === maxDeadline ? "maximum" : "idle"), Math.max(0, scheduledDeadline - Date.now()));
|
|
407
|
+
deadlineTimer.unref();
|
|
408
|
+
};
|
|
409
|
+
const observeEvent = () => {
|
|
410
|
+
if (callbackFailure || aborted || timedOutAfterMs !== undefined || childExited)
|
|
411
|
+
return;
|
|
412
|
+
const now = Date.now();
|
|
413
|
+
if (now >= deadline) {
|
|
414
|
+
timeout(deadline - startedAt, deadline === maxDeadline ? "maximum" : "idle");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
lastEventAt = now;
|
|
418
|
+
scheduleDeadline();
|
|
419
|
+
};
|
|
420
|
+
const processLine = (line) => {
|
|
421
|
+
if (!line.trim())
|
|
422
|
+
return;
|
|
423
|
+
let event;
|
|
424
|
+
try {
|
|
425
|
+
event = JSON.parse(line);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (!event || typeof event !== "object" || Array.isArray(event))
|
|
431
|
+
return;
|
|
432
|
+
const record = event;
|
|
433
|
+
if (typeof record.type !== "string" || !Object.hasOwn(PI_JSON_EVENTS, record.type))
|
|
434
|
+
return;
|
|
435
|
+
observeEvent();
|
|
436
|
+
if (record.type === "message_start") {
|
|
437
|
+
partial.prefix = "";
|
|
438
|
+
partial.totalBytes = 0;
|
|
439
|
+
hasPartialText = false;
|
|
440
|
+
currentUsage = undefined;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (record.type === "message_update") {
|
|
444
|
+
const tokens = usageTokens(record.usage);
|
|
445
|
+
if (tokens !== undefined) {
|
|
446
|
+
currentTokens = tokens;
|
|
447
|
+
invokeCallback("onTokens", input.onTokens, completedTokens + currentTokens);
|
|
448
|
+
}
|
|
449
|
+
currentUsage = usageFrom(record.usage) ?? currentUsage;
|
|
450
|
+
const update = record.assistantMessageEvent;
|
|
451
|
+
if (update && typeof update === "object" && !Array.isArray(update)) {
|
|
452
|
+
const assistantEvent = update;
|
|
453
|
+
if (assistantEvent.type === "text_start" && hasPartialText)
|
|
454
|
+
appendBounded(partial, "\n");
|
|
455
|
+
if (assistantEvent.type === "text_start")
|
|
456
|
+
hasPartialText = true;
|
|
457
|
+
if (assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string") {
|
|
458
|
+
hasPartialText = true;
|
|
459
|
+
appendBounded(partial, assistantEvent.delta);
|
|
460
|
+
output = boundedText(partial);
|
|
461
|
+
invokeCallback("onUpdate", input.onUpdate, output);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (record.type !== "message_end")
|
|
467
|
+
return;
|
|
468
|
+
const text = assistantText(record.message);
|
|
469
|
+
if (text !== undefined) {
|
|
470
|
+
output = capEphemeralSubagentOutput(text);
|
|
471
|
+
invokeCallback("onUpdate", input.onUpdate, output);
|
|
472
|
+
}
|
|
473
|
+
if (record.message && typeof record.message === "object" && !Array.isArray(record.message)) {
|
|
474
|
+
const message = record.message;
|
|
475
|
+
if (message.role === "assistant") {
|
|
476
|
+
const finalUsage = usageFrom(message.usage) ?? currentUsage;
|
|
477
|
+
completedUsage = addUsage(completedUsage, finalUsage);
|
|
478
|
+
completedTokens += usageTokens(message.usage) ?? currentTokens;
|
|
479
|
+
currentTokens = 0;
|
|
480
|
+
currentUsage = undefined;
|
|
481
|
+
invokeCallback("onTokens", input.onTokens, completedTokens);
|
|
482
|
+
}
|
|
483
|
+
if (typeof message.stopReason === "string")
|
|
484
|
+
stopReason = message.stopReason;
|
|
485
|
+
if (typeof message.errorMessage === "string")
|
|
486
|
+
errorMessage = message.errorMessage;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
async function killTree(force) {
|
|
490
|
+
if (!child.pid)
|
|
491
|
+
return;
|
|
492
|
+
if (process.platform === "win32") {
|
|
493
|
+
await new Promise((done) => {
|
|
494
|
+
const taskkill = spawn("taskkill", [...(force ? ["/F"] : []), "/T", "/PID", String(child.pid)], {
|
|
495
|
+
stdio: "ignore",
|
|
496
|
+
windowsHide: true,
|
|
497
|
+
});
|
|
498
|
+
taskkill.once("error", () => done());
|
|
499
|
+
taskkill.once("close", () => done());
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
child.stdout.on("data", (data) => {
|
|
511
|
+
if (callbackFailure || protocolError)
|
|
512
|
+
return;
|
|
513
|
+
let offset = 0;
|
|
514
|
+
while (offset < data.length) {
|
|
515
|
+
const newline = data.indexOf("\n", offset);
|
|
516
|
+
const end = newline === -1 ? data.length : newline;
|
|
517
|
+
const part = data.slice(offset, end);
|
|
518
|
+
if (!ignoreLine) {
|
|
519
|
+
linePrefix += part.slice(0, Math.max(0, 256 - linePrefix.length));
|
|
520
|
+
const eventType = JSON_EVENT_TYPE.exec(linePrefix)?.[1];
|
|
521
|
+
if (eventType && !lineEventType)
|
|
522
|
+
lineEventType = eventType;
|
|
523
|
+
lineBytes += Buffer.byteLength(part, "utf8");
|
|
524
|
+
if (lineBytes > MAX_JSON_EVENT_BYTES) {
|
|
525
|
+
if (lineEventType && !CONSUMED_JSON_EVENTS.has(lineEventType)) {
|
|
526
|
+
ignoreLine = true;
|
|
527
|
+
lineParts = [];
|
|
528
|
+
lineBytes = 0;
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
protocolError = new Error(`Subagent JSON event exceeds ${MAX_JSON_EVENT_BYTES} bytes.`);
|
|
532
|
+
void killTree(true);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
else if (part)
|
|
537
|
+
lineParts.push(part);
|
|
538
|
+
}
|
|
539
|
+
if (newline === -1)
|
|
540
|
+
return;
|
|
541
|
+
if (!ignoreLine)
|
|
542
|
+
processLine(lineParts.join(""));
|
|
543
|
+
if (callbackFailure)
|
|
544
|
+
return;
|
|
545
|
+
lineParts = [];
|
|
546
|
+
lineBytes = 0;
|
|
547
|
+
linePrefix = "";
|
|
548
|
+
lineEventType = undefined;
|
|
549
|
+
ignoreLine = false;
|
|
550
|
+
offset = newline + 1;
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
child.stderr.on("data", (data) => {
|
|
554
|
+
appendBounded(stderr, data);
|
|
555
|
+
});
|
|
556
|
+
child.on("error", (error) => { spawnError = error; });
|
|
557
|
+
function stop(force = false) {
|
|
558
|
+
if (force) {
|
|
559
|
+
void killTree(true);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
void killTree(false);
|
|
563
|
+
killTimer = setTimeout(() => void killTree(true), Math.min(5_000, Math.max(0, maxDeadline - Date.now())));
|
|
564
|
+
killTimer.unref();
|
|
565
|
+
}
|
|
566
|
+
const abort = () => {
|
|
567
|
+
if (timedOutAfterMs !== undefined || childExited)
|
|
568
|
+
return;
|
|
569
|
+
aborted = true;
|
|
570
|
+
stop();
|
|
571
|
+
};
|
|
572
|
+
function timeout(afterMs, reason) {
|
|
573
|
+
if (timedOutAfterMs !== undefined || childExited)
|
|
574
|
+
return;
|
|
575
|
+
if (reason === "maximum") {
|
|
576
|
+
if (!aborted) {
|
|
577
|
+
timedOutAfterMs = afterMs;
|
|
578
|
+
timeoutReason = reason;
|
|
579
|
+
}
|
|
580
|
+
stop(true);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (aborted)
|
|
584
|
+
return;
|
|
585
|
+
timedOutAfterMs = afterMs;
|
|
586
|
+
timeoutReason = reason;
|
|
587
|
+
stop();
|
|
588
|
+
}
|
|
589
|
+
scheduleDeadline();
|
|
590
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
591
|
+
if (input.signal?.aborted)
|
|
592
|
+
abort();
|
|
593
|
+
// `close` waits for stdio EOF, which descendants can hold after Pi exits.
|
|
594
|
+
// Kill the process group at Pi's exit boundary so `close` can settle.
|
|
595
|
+
child.once("exit", () => {
|
|
596
|
+
childExited = true;
|
|
597
|
+
if (deadlineTimer)
|
|
598
|
+
clearTimeout(deadlineTimer);
|
|
599
|
+
input.signal?.removeEventListener("abort", abort);
|
|
600
|
+
void killTree(true);
|
|
601
|
+
});
|
|
602
|
+
child.on("close", async (code) => {
|
|
603
|
+
if (!callbackFailure && !protocolError && lineBytes)
|
|
604
|
+
processLine(lineParts.join(""));
|
|
605
|
+
await killTree(true);
|
|
606
|
+
// A caller callback that never settles must not hold `run()` or its permit
|
|
607
|
+
// past child exit; bound the drain by what remains of the maximum runtime.
|
|
608
|
+
const drainTimer = setTimeout(() => {
|
|
609
|
+
callbackFailure ??= new EphemeralSubagentError("callback", "Subagent callback did not settle before the post-exit drain deadline.");
|
|
610
|
+
signalCallbackFailure();
|
|
611
|
+
}, Math.min(5_000, Math.max(0, maxDeadline - Date.now())));
|
|
612
|
+
await Promise.race([Promise.all(pendingCallbacks), callbackFailed]);
|
|
613
|
+
clearTimeout(drainTimer);
|
|
614
|
+
if (deadlineTimer)
|
|
615
|
+
clearTimeout(deadlineTimer);
|
|
616
|
+
if (killTimer)
|
|
617
|
+
clearTimeout(killTimer);
|
|
618
|
+
input.signal?.removeEventListener("abort", abort);
|
|
619
|
+
if (callbackFailure) {
|
|
620
|
+
reject(new EphemeralSubagentError("callback", callbackFailure.message, callbackFailure.cause, accumulatedUsage()));
|
|
621
|
+
}
|
|
622
|
+
else if (aborted)
|
|
623
|
+
reject(abortError(input.signal, input.signal?.reason, accumulatedUsage()));
|
|
624
|
+
else if (timedOutAfterMs !== undefined) {
|
|
625
|
+
const message = timeoutReason === "maximum"
|
|
626
|
+
? `Subagent reached its maximum runtime after ${formatDuration(timedOutAfterMs)}.`
|
|
627
|
+
: `Subagent timed out after ${formatDuration(timeoutPolicy.idleMs)} without a recognized Pi event.`;
|
|
628
|
+
reject(new EphemeralSubagentError("timeout", message, new Error(message), accumulatedUsage()));
|
|
629
|
+
}
|
|
630
|
+
else if (protocolError) {
|
|
631
|
+
reject(new EphemeralSubagentError("protocol", protocolError.message, protocolError, accumulatedUsage()));
|
|
632
|
+
}
|
|
633
|
+
else if (spawnError) {
|
|
634
|
+
reject(new EphemeralSubagentError("spawn", spawnError.message, spawnError));
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
const exitCode = code ?? 1;
|
|
638
|
+
const outcome = exitCode !== 0 || stopReason === "error" || stopReason === "aborted" ? "failure" : "success";
|
|
639
|
+
resolve({
|
|
640
|
+
outcome,
|
|
641
|
+
exitCode,
|
|
642
|
+
output,
|
|
643
|
+
stderr: boundedText(stderr),
|
|
644
|
+
stopReason,
|
|
645
|
+
errorMessage,
|
|
646
|
+
usage: addUsage(completedUsage, currentUsage),
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type HerdrExecutor } from "@henryqw/pi-herdr";
|
|
3
3
|
import { type AvailableModel, type ProfileName, type ResolvedTaskRoute, type ThinkingLevel } from "@henryqw/pi-task-models";
|
|
4
|
+
export { capEphemeralSubagentOutput, createEphemeralSubagentExecutor, EphemeralSubagentError, type EphemeralSubagentErrorCode, type EphemeralSubagentExecutor, type EphemeralSubagentExecutorOptions, type EphemeralSubagentResult, type EphemeralSubagentRunInput, type EphemeralSubagentTimeout, } from "./ephemeral.ts";
|
|
4
5
|
export { createChildWorktree, finalizeChildWorktree, worktreeContextNote, type WorktreeInfo, type WorktreePayload, } from "./worktree.ts";
|
|
5
6
|
export interface Role {
|
|
6
7
|
name: string;
|
|
@@ -39,8 +40,8 @@ export declare const isProfileName: (value: unknown) => value is ProfileName;
|
|
|
39
40
|
export declare function loadRoles(agentDir?: string): Role[];
|
|
40
41
|
export declare function resolveTaskRoute(ctx: ExtensionContext, profileName: ProfileName, agentDir?: string, thinking?: ThinkingLevel): ResolvedTaskRoute;
|
|
41
42
|
export declare function resolveRoleSkills(pi: Pick<ExtensionAPI, "getCommands">, role: Role): ResolvedRoleSkills;
|
|
42
|
-
export declare function createRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: Pick<ExtensionContext, "isProjectTrusted">, input: CreateRoleLaunchInput): ResolvedRoleLaunch;
|
|
43
|
-
export declare function resolveRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: ExtensionContext, input: ResolveRoleLaunchInput): ResolvedRoleLaunch;
|
|
43
|
+
export declare function createRoleLaunch(pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools" | "getCommands">, ctx: Pick<ExtensionContext, "isProjectTrusted">, input: CreateRoleLaunchInput): ResolvedRoleLaunch;
|
|
44
|
+
export declare function resolveRoleLaunch(pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools" | "getCommands">, ctx: ExtensionContext, input: ResolveRoleLaunchInput): ResolvedRoleLaunch;
|
|
44
45
|
export interface ManagedSubagentHost {
|
|
45
46
|
cwd: string;
|
|
46
47
|
workspaceId: string;
|