@rahularya01/pi-essentials 0.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/LICENSE +21 -0
- package/README.md +324 -0
- package/examples/mcp.json +30 -0
- package/examples/pi-essentials.json +32 -0
- package/examples/pi-settings.json +5 -0
- package/package.json +88 -0
- package/skills/pi-essentials/SKILL.md +50 -0
- package/src/config.ts +351 -0
- package/src/errors.ts +96 -0
- package/src/index.ts +43 -0
- package/src/mcp/commands.ts +390 -0
- package/src/mcp/config.ts +157 -0
- package/src/mcp/credential-store.ts +153 -0
- package/src/mcp/index.ts +67 -0
- package/src/mcp/manager.ts +941 -0
- package/src/mcp/oauth.ts +262 -0
- package/src/mcp/proxy-tool.ts +213 -0
- package/src/mcp/render.ts +164 -0
- package/src/mcp/types.ts +63 -0
- package/src/paths.ts +48 -0
- package/src/questions/ask.ts +134 -0
- package/src/questions/index.ts +72 -0
- package/src/questions/render.ts +69 -0
- package/src/questions/validate.ts +85 -0
- package/src/security/env.ts +132 -0
- package/src/security/limits.ts +20 -0
- package/src/security/ssrf.ts +237 -0
- package/src/subagents/activity.ts +132 -0
- package/src/subagents/builtins/oracle.md +11 -0
- package/src/subagents/builtins/reviewer.md +11 -0
- package/src/subagents/builtins/scout.md +12 -0
- package/src/subagents/builtins/worker.md +11 -0
- package/src/subagents/discover.ts +54 -0
- package/src/subagents/herdr.ts +150 -0
- package/src/subagents/index.ts +642 -0
- package/src/subagents/inspector-tail.d.mts +1 -0
- package/src/subagents/inspector-tail.mjs +140 -0
- package/src/subagents/render.ts +464 -0
- package/src/subagents/runner.ts +468 -0
- package/src/subagents/schema.ts +107 -0
- package/src/subagents/types.ts +131 -0
- package/src/subagents/worktree.ts +131 -0
- package/src/todos/index.ts +170 -0
- package/src/todos/render.ts +198 -0
- package/src/todos/state.ts +310 -0
- package/src/ui/render.ts +215 -0
- package/src/web/activity.ts +91 -0
- package/src/web/cache.ts +153 -0
- package/src/web/extract.ts +75 -0
- package/src/web/fetch.ts +167 -0
- package/src/web/html-to-markdown.ts +284 -0
- package/src/web/http.ts +238 -0
- package/src/web/index.ts +214 -0
- package/src/web/providers/brave.ts +27 -0
- package/src/web/providers/duckduckgo.ts +60 -0
- package/src/web/providers/exa.ts +29 -0
- package/src/web/providers/jina.ts +25 -0
- package/src/web/providers/searxng.ts +29 -0
- package/src/web/providers/tavily.ts +31 -0
- package/src/web/providers/types.ts +75 -0
- package/src/web/render.ts +130 -0
- package/src/web/search.ts +108 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { ResolvedSubagentsConfig } from "../config.ts";
|
|
6
|
+
import { capBytes } from "../errors.ts";
|
|
7
|
+
import { sanitizeEnv } from "../security/env.ts";
|
|
8
|
+
import { applySessionEvent, emptyTrace, type TraceEvent } from "./activity.ts";
|
|
9
|
+
import type { JsonSchema } from "./schema.ts";
|
|
10
|
+
import type { AgentDefinition } from "./types.ts";
|
|
11
|
+
import type { WorktreeMetadata } from "./worktree.ts";
|
|
12
|
+
|
|
13
|
+
/** Grace period between SIGTERM and SIGKILL when a run is cancelled. */
|
|
14
|
+
const KILL_GRACE_MS = 5_000;
|
|
15
|
+
|
|
16
|
+
/** How long a finished run's event log survives, so a Herdr pane opened just after completion still works. */
|
|
17
|
+
const LOG_RETENTION_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
export interface RunUsage {
|
|
20
|
+
input: number;
|
|
21
|
+
output: number;
|
|
22
|
+
cacheRead: number;
|
|
23
|
+
cacheWrite: number;
|
|
24
|
+
cost: number;
|
|
25
|
+
costInput?: number;
|
|
26
|
+
costOutput?: number;
|
|
27
|
+
costCacheRead?: number;
|
|
28
|
+
costCacheWrite?: number;
|
|
29
|
+
turns: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SubagentRunResult {
|
|
33
|
+
id: string;
|
|
34
|
+
agent: string;
|
|
35
|
+
task: string;
|
|
36
|
+
exitCode: number;
|
|
37
|
+
output: string;
|
|
38
|
+
structuredOutput?: unknown;
|
|
39
|
+
outputKind: "text" | "structured";
|
|
40
|
+
stderr: string;
|
|
41
|
+
usage: RunUsage;
|
|
42
|
+
worktree?: WorktreeMetadata;
|
|
43
|
+
model?: string;
|
|
44
|
+
error?: string;
|
|
45
|
+
running: boolean;
|
|
46
|
+
durationMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ActiveRun {
|
|
50
|
+
id: string;
|
|
51
|
+
agent: string;
|
|
52
|
+
task: string;
|
|
53
|
+
startedAt: number;
|
|
54
|
+
proc?: ReturnType<typeof spawn>;
|
|
55
|
+
/** One-line current action (tool or last assistant snippet). */
|
|
56
|
+
activity: string;
|
|
57
|
+
/** Structured live transcript for the inspector. */
|
|
58
|
+
events: TraceEvent[];
|
|
59
|
+
/** Raw NDJSON event log for this run, for the external Herdr inspector viewer. */
|
|
60
|
+
logFile?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let runCounter = 0;
|
|
64
|
+
|
|
65
|
+
export function emptyUsage(): RunUsage {
|
|
66
|
+
return {
|
|
67
|
+
input: 0,
|
|
68
|
+
output: 0,
|
|
69
|
+
cacheRead: 0,
|
|
70
|
+
cacheWrite: 0,
|
|
71
|
+
cost: 0,
|
|
72
|
+
costInput: 0,
|
|
73
|
+
costOutput: 0,
|
|
74
|
+
costCacheRead: 0,
|
|
75
|
+
costCacheWrite: 0,
|
|
76
|
+
turns: 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve `pi` on PATH ourselves. `spawn` does no PATHEXT resolution and refuses
|
|
82
|
+
* to run `.cmd`/`.bat` without a shell, which we will not use for untrusted text.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveOnPath(name: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
85
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
|
|
86
|
+
const extensions =
|
|
87
|
+
process.platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
|
|
88
|
+
for (const dir of dirs) {
|
|
89
|
+
for (const ext of extensions) {
|
|
90
|
+
const candidate = path.join(dir, `${name}${ext}`);
|
|
91
|
+
try {
|
|
92
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
93
|
+
} catch {
|
|
94
|
+
// Not here; keep looking.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
102
|
+
const currentScript = process.argv[1];
|
|
103
|
+
const isBunVirtual = currentScript?.startsWith("/$bunfs/root/");
|
|
104
|
+
// Preferred: re-run the exact script this process was started from.
|
|
105
|
+
if (currentScript && !isBunVirtual && fs.existsSync(currentScript)) {
|
|
106
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
107
|
+
}
|
|
108
|
+
// A compiled pi binary: re-exec it directly.
|
|
109
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
110
|
+
if (!/^(node|bun)(\.exe)?$/.test(execName)) {
|
|
111
|
+
return { command: process.execPath, args };
|
|
112
|
+
}
|
|
113
|
+
return { command: resolveOnPath("pi") ?? "pi", args };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function writePrompt(agentName: string, prompt: string): { dir: string; file: string } {
|
|
117
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-essentials-subagent-"));
|
|
118
|
+
const file = path.join(dir, `${agentName.replace(/[^\w.-]+/g, "_") || "agent"}.md`);
|
|
119
|
+
fs.writeFileSync(file, prompt, { encoding: "utf8", mode: 0o600 });
|
|
120
|
+
return { dir, file };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildResultExtension(schema: JsonSchema): string {
|
|
124
|
+
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";\n\nconst schema = ${JSON.stringify(schema)} as any;\n\nexport default function (pi: ExtensionAPI) {\n pi.registerTool({\n name: "subagent_result",\n label: "Subagent Result",\n description: "Submit the final structured result. This must be your final action.",\n parameters: schema,\n async execute(_id, params) {\n return { content: [{ type: "text", text: JSON.stringify(params) }], details: params, terminate: true };\n },\n });\n}\n`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function cleanupPrompt(dir: string): void {
|
|
128
|
+
try {
|
|
129
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
130
|
+
} catch {
|
|
131
|
+
// Best-effort; the OS reclaims the temp directory anyway.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function finalAssistantText(messages: Array<{ role?: string; content?: unknown }>): string {
|
|
136
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
137
|
+
const msg = messages[i];
|
|
138
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
139
|
+
const texts = msg.content
|
|
140
|
+
.filter(
|
|
141
|
+
(part): part is { type: string; text: string } =>
|
|
142
|
+
Boolean(part) &&
|
|
143
|
+
typeof part === "object" &&
|
|
144
|
+
(part as { type?: unknown }).type === "text" &&
|
|
145
|
+
typeof (part as { text?: unknown }).text === "string",
|
|
146
|
+
)
|
|
147
|
+
.map((part) => part.text.trim())
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
if (texts.length > 0) return texts.join("\n");
|
|
150
|
+
}
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface JsonUsage {
|
|
155
|
+
input?: number;
|
|
156
|
+
output?: number;
|
|
157
|
+
cacheRead?: number;
|
|
158
|
+
cacheWrite?: number;
|
|
159
|
+
cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Accumulate pi's JSON-mode usage, whose cost lives under `usage.cost.total`. */
|
|
163
|
+
export function accumulateUsage(usage: RunUsage, reported: JsonUsage | undefined): void {
|
|
164
|
+
if (!reported) return;
|
|
165
|
+
usage.input += reported.input ?? 0;
|
|
166
|
+
usage.output += reported.output ?? 0;
|
|
167
|
+
usage.cacheRead += reported.cacheRead ?? 0;
|
|
168
|
+
usage.cacheWrite += reported.cacheWrite ?? 0;
|
|
169
|
+
usage.cost += reported.cost?.total ?? 0;
|
|
170
|
+
usage.costInput = (usage.costInput ?? 0) + (reported.cost?.input ?? 0);
|
|
171
|
+
usage.costOutput = (usage.costOutput ?? 0) + (reported.cost?.output ?? 0);
|
|
172
|
+
usage.costCacheRead = (usage.costCacheRead ?? 0) + (reported.cost?.cacheRead ?? 0);
|
|
173
|
+
usage.costCacheWrite = (usage.costCacheWrite ?? 0) + (reported.cost?.cacheWrite ?? 0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function buildArgs(options: {
|
|
177
|
+
agent: AgentDefinition;
|
|
178
|
+
model?: string;
|
|
179
|
+
thinkingLevel?: string;
|
|
180
|
+
promptFile?: string;
|
|
181
|
+
extensionFile?: string;
|
|
182
|
+
structured?: boolean;
|
|
183
|
+
task: string;
|
|
184
|
+
}): string[] {
|
|
185
|
+
const args = ["--mode", "json", "--no-session"];
|
|
186
|
+
const model = options.agent.model ?? options.model;
|
|
187
|
+
if (model) args.push("--model", model);
|
|
188
|
+
if (options.thinkingLevel) args.push("--thinking", options.thinkingLevel);
|
|
189
|
+
if (options.extensionFile) args.push("--extension", options.extensionFile);
|
|
190
|
+
if (options.agent.tools?.length) {
|
|
191
|
+
const tools = options.structured
|
|
192
|
+
? [...new Set([...options.agent.tools, "subagent_result"])]
|
|
193
|
+
: options.agent.tools;
|
|
194
|
+
args.push("--tools", tools.join(","));
|
|
195
|
+
}
|
|
196
|
+
if (options.promptFile) args.push("--append-system-prompt", options.promptFile);
|
|
197
|
+
// `--` stops flag parsing so a task starting with "-" is still treated as a prompt.
|
|
198
|
+
args.push("--", `Task: ${options.task}`);
|
|
199
|
+
return args;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function runSubagent(options: {
|
|
203
|
+
agent: AgentDefinition;
|
|
204
|
+
task: string;
|
|
205
|
+
cwd: string;
|
|
206
|
+
model?: string;
|
|
207
|
+
thinkingLevel?: string;
|
|
208
|
+
config: ResolvedSubagentsConfig;
|
|
209
|
+
signal?: AbortSignal;
|
|
210
|
+
envExtra?: Record<string, string>;
|
|
211
|
+
outputSchema?: JsonSchema;
|
|
212
|
+
onProgress?: (partial: string) => void;
|
|
213
|
+
register?: (run: ActiveRun) => void;
|
|
214
|
+
unregister?: (id: string) => void;
|
|
215
|
+
}): Promise<SubagentRunResult> {
|
|
216
|
+
const startedAt = Date.now();
|
|
217
|
+
const id = `${options.agent.name}-${(runCounter++).toString(36)}${startedAt.toString(36).slice(-4)}`;
|
|
218
|
+
|
|
219
|
+
let promptDir: string | undefined;
|
|
220
|
+
let promptFile: string | undefined;
|
|
221
|
+
const structuredGuidance = options.outputSchema
|
|
222
|
+
? "\n\nYou MUST finish by calling subagent_result exactly once with the final answer. Do not merely print JSON."
|
|
223
|
+
: "";
|
|
224
|
+
const systemPrompt = `${options.agent.systemPrompt}${structuredGuidance}`;
|
|
225
|
+
if (systemPrompt.trim()) {
|
|
226
|
+
try {
|
|
227
|
+
const written = writePrompt(options.agent.name, systemPrompt);
|
|
228
|
+
promptDir = written.dir;
|
|
229
|
+
promptFile = written.file;
|
|
230
|
+
} catch {
|
|
231
|
+
// Fall back to passing the prompt inline; pi accepts text or a file path.
|
|
232
|
+
promptFile = systemPrompt;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let extensionFile: string | undefined;
|
|
237
|
+
if (options.outputSchema) {
|
|
238
|
+
if (!promptDir) promptDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-essentials-subagent-"));
|
|
239
|
+
extensionFile = path.join(promptDir, "structured-result.ts");
|
|
240
|
+
fs.writeFileSync(extensionFile, buildResultExtension(options.outputSchema), { encoding: "utf8", mode: 0o600 });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const args = buildArgs({
|
|
244
|
+
agent: options.agent,
|
|
245
|
+
model: options.model,
|
|
246
|
+
// An agent that pins its own model should not inherit the parent's thinking level.
|
|
247
|
+
thinkingLevel: options.agent.model ? undefined : options.thinkingLevel,
|
|
248
|
+
promptFile,
|
|
249
|
+
extensionFile,
|
|
250
|
+
structured: Boolean(options.outputSchema),
|
|
251
|
+
task: options.task,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const usage = emptyUsage();
|
|
255
|
+
const messages: Array<{ role?: string; content?: unknown }> = [];
|
|
256
|
+
let stderr = "";
|
|
257
|
+
let childModel = options.agent.model ?? options.model;
|
|
258
|
+
let error: string | undefined;
|
|
259
|
+
let structuredOutput: unknown;
|
|
260
|
+
let successfulResultCalls = 0;
|
|
261
|
+
|
|
262
|
+
const invocation = getPiInvocation(args);
|
|
263
|
+
const env = sanitizeEnv(process.env, {
|
|
264
|
+
PI_ESSENTIALS_SUBAGENT: "1",
|
|
265
|
+
PI_ESSENTIALS_NEST_DEPTH: String(Math.max(0, Number.parseInt(process.env.PI_ESSENTIALS_NEST_DEPTH ?? "0", 10) || 0) + 1),
|
|
266
|
+
...options.envExtra,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
270
|
+
let settled = false;
|
|
271
|
+
const finish = (code: number) => {
|
|
272
|
+
if (settled) return;
|
|
273
|
+
settled = true;
|
|
274
|
+
resolve(code);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
let proc: ReturnType<typeof spawn>;
|
|
278
|
+
try {
|
|
279
|
+
proc = spawn(invocation.command, invocation.args, {
|
|
280
|
+
cwd: options.cwd,
|
|
281
|
+
env,
|
|
282
|
+
shell: false,
|
|
283
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
284
|
+
});
|
|
285
|
+
} catch (spawnError) {
|
|
286
|
+
error = `Could not start "${invocation.command}": ${(spawnError as Error).message}`;
|
|
287
|
+
finish(1);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let logStream: fs.WriteStream | undefined;
|
|
292
|
+
let logDir: string | undefined;
|
|
293
|
+
let logFile: string | undefined;
|
|
294
|
+
try {
|
|
295
|
+
logDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-essentials-subagent-log-"));
|
|
296
|
+
logFile = path.join(logDir, "events.jsonl");
|
|
297
|
+
logStream = fs.createWriteStream(logFile, { mode: 0o600 });
|
|
298
|
+
logStream.write(`${JSON.stringify({ type: "__meta__", agent: options.agent.name, task: options.task })}\n`);
|
|
299
|
+
} catch {
|
|
300
|
+
// Best-effort; the Herdr inspector just won't be available for this run.
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const trace = emptyTrace();
|
|
304
|
+
const run: ActiveRun = {
|
|
305
|
+
id,
|
|
306
|
+
agent: options.agent.name,
|
|
307
|
+
task: options.task,
|
|
308
|
+
startedAt,
|
|
309
|
+
proc,
|
|
310
|
+
activity: "",
|
|
311
|
+
events: trace.events,
|
|
312
|
+
logFile,
|
|
313
|
+
};
|
|
314
|
+
options.register?.(run);
|
|
315
|
+
|
|
316
|
+
const publish = () => {
|
|
317
|
+
run.activity = trace.activity;
|
|
318
|
+
run.events = trace.events;
|
|
319
|
+
options.onProgress?.(trace.activity || finalAssistantText(messages) || "(running…)");
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
let buffer = "";
|
|
323
|
+
const handleLine = (line: string) => {
|
|
324
|
+
if (!line.trim()) return;
|
|
325
|
+
logStream?.write(`${line}\n`);
|
|
326
|
+
let event: unknown;
|
|
327
|
+
try {
|
|
328
|
+
event = JSON.parse(line);
|
|
329
|
+
} catch {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!event || typeof event !== "object") return;
|
|
333
|
+
const rec = event as {
|
|
334
|
+
type?: string;
|
|
335
|
+
toolName?: string;
|
|
336
|
+
isError?: boolean;
|
|
337
|
+
result?: { details?: unknown };
|
|
338
|
+
message?: { role?: string; content?: unknown; usage?: JsonUsage; model?: string; errorMessage?: string; stopReason?: string };
|
|
339
|
+
};
|
|
340
|
+
if (rec.type === "tool_execution_end" && rec.toolName === "subagent_result" && !rec.isError) {
|
|
341
|
+
successfulResultCalls += 1;
|
|
342
|
+
structuredOutput = rec.result?.details;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const live = applySessionEvent(trace, rec);
|
|
346
|
+
if (rec.type === "message_end" && rec.message) {
|
|
347
|
+
messages.push(rec.message);
|
|
348
|
+
if (rec.message.role === "assistant") {
|
|
349
|
+
usage.turns += 1;
|
|
350
|
+
accumulateUsage(usage, rec.message.usage);
|
|
351
|
+
if (rec.message.model) childModel = rec.message.model;
|
|
352
|
+
if (rec.message.errorMessage) error = rec.message.errorMessage;
|
|
353
|
+
else if (rec.message.stopReason === "error" || rec.message.stopReason === "aborted") {
|
|
354
|
+
error = `Subagent stopped with reason: ${rec.message.stopReason}.`;
|
|
355
|
+
}
|
|
356
|
+
} else if (rec.message.role === "toolResult") {
|
|
357
|
+
accumulateUsage(usage, rec.message.usage);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (live || rec.type === "message_end") publish();
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
proc.stdout?.setEncoding("utf8");
|
|
364
|
+
proc.stdout?.on("data", (chunk: string) => {
|
|
365
|
+
buffer += chunk;
|
|
366
|
+
const lines = buffer.split("\n");
|
|
367
|
+
buffer = lines.pop() ?? "";
|
|
368
|
+
for (const line of lines) handleLine(line);
|
|
369
|
+
});
|
|
370
|
+
proc.stderr?.setEncoding("utf8");
|
|
371
|
+
proc.stderr?.on("data", (chunk: string) => {
|
|
372
|
+
// Keep only the tail; a noisy child must not grow memory without bound.
|
|
373
|
+
stderr = (stderr + chunk).slice(-8000);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
377
|
+
const kill = () => {
|
|
378
|
+
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
379
|
+
proc.kill("SIGTERM");
|
|
380
|
+
killTimer = setTimeout(() => {
|
|
381
|
+
if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGKILL");
|
|
382
|
+
}, KILL_GRACE_MS);
|
|
383
|
+
killTimer.unref?.();
|
|
384
|
+
};
|
|
385
|
+
const signal = options.signal;
|
|
386
|
+
if (signal) {
|
|
387
|
+
if (signal.aborted) kill();
|
|
388
|
+
else signal.addEventListener("abort", kill, { once: true });
|
|
389
|
+
}
|
|
390
|
+
const detach = () => {
|
|
391
|
+
if (killTimer) clearTimeout(killTimer);
|
|
392
|
+
signal?.removeEventListener("abort", kill);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
proc.on("close", (code, closeSignal) => {
|
|
396
|
+
if (buffer.trim()) handleLine(buffer);
|
|
397
|
+
detach();
|
|
398
|
+
logStream?.end();
|
|
399
|
+
if (logDir) {
|
|
400
|
+
const cleanup = setTimeout(() => fs.rmSync(logDir, { recursive: true, force: true }), LOG_RETENTION_MS);
|
|
401
|
+
cleanup.unref?.();
|
|
402
|
+
}
|
|
403
|
+
if (closeSignal && code === null) {
|
|
404
|
+
error ??= `Subagent was terminated by ${closeSignal}.`;
|
|
405
|
+
finish(1);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
finish(code ?? 0);
|
|
409
|
+
});
|
|
410
|
+
proc.on("error", (err) => {
|
|
411
|
+
const message = (err as NodeJS.ErrnoException).code === "ENOENT"
|
|
412
|
+
? `Could not find the "pi" executable to run subagents. Ensure pi is on PATH.`
|
|
413
|
+
: err.message;
|
|
414
|
+
error = message;
|
|
415
|
+
detach();
|
|
416
|
+
finish(1);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
options.unregister?.(id);
|
|
421
|
+
if (promptDir) cleanupPrompt(promptDir);
|
|
422
|
+
|
|
423
|
+
const answer = finalAssistantText(messages);
|
|
424
|
+
if (options.outputSchema && successfulResultCalls === 0) {
|
|
425
|
+
error ??= "Structured subagent run ended without a successful subagent_result call.";
|
|
426
|
+
} else if (!options.outputSchema && !answer && !error) {
|
|
427
|
+
error = stderr.trim()
|
|
428
|
+
? `Subagent produced no answer. stderr:\n${stderr.trim()}`
|
|
429
|
+
: "Subagent completed without an assistant answer.";
|
|
430
|
+
}
|
|
431
|
+
const outputKind = options.outputSchema ? "structured" : "text";
|
|
432
|
+
const authoritativeOutput = options.outputSchema && successfulResultCalls > 0
|
|
433
|
+
? JSON.stringify(structuredOutput)
|
|
434
|
+
: answer;
|
|
435
|
+
const fallback = error ?? "(no output)";
|
|
436
|
+
return {
|
|
437
|
+
id,
|
|
438
|
+
agent: options.agent.name,
|
|
439
|
+
task: options.task,
|
|
440
|
+
exitCode: error ? (exitCode || 1) : exitCode,
|
|
441
|
+
output: capBytes(authoritativeOutput || fallback, options.config.maxOutputBytes).text,
|
|
442
|
+
structuredOutput: options.outputSchema && successfulResultCalls > 0 ? structuredOutput : undefined,
|
|
443
|
+
outputKind,
|
|
444
|
+
stderr: stderr.slice(-4000),
|
|
445
|
+
usage,
|
|
446
|
+
model: childModel,
|
|
447
|
+
error,
|
|
448
|
+
running: false,
|
|
449
|
+
durationMs: Date.now() - startedAt,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
454
|
+
if (items.length === 0) return [];
|
|
455
|
+
const concurrency = Math.max(1, Math.min(Math.floor(limit) || 1, items.length));
|
|
456
|
+
const results = new Array<R>(items.length);
|
|
457
|
+
let next = 0;
|
|
458
|
+
await Promise.all(
|
|
459
|
+
Array.from({ length: concurrency }, async () => {
|
|
460
|
+
while (true) {
|
|
461
|
+
const index = next++;
|
|
462
|
+
if (index >= items.length) return;
|
|
463
|
+
results[index] = await fn(items[index], index);
|
|
464
|
+
}
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
return results;
|
|
468
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const MAX_SCHEMA_BYTES = 32 * 1024;
|
|
2
|
+
const MAX_SCHEMA_DEPTH = 16;
|
|
3
|
+
const MAX_SCHEMA_NODES = 1_000;
|
|
4
|
+
const DANGEROUS_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
5
|
+
const JSON_SCHEMA_TYPES = new Set(["null", "boolean", "object", "array", "number", "integer", "string"]);
|
|
6
|
+
const SCHEMA_MAP_KEYS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]);
|
|
7
|
+
const SCHEMA_ARRAY_KEYS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
|
|
8
|
+
const SCHEMA_VALUE_KEYS = new Set([
|
|
9
|
+
"additionalProperties", "unevaluatedProperties", "items", "contains", "not", "if", "then", "else",
|
|
10
|
+
"propertyNames", "unevaluatedItems",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export type JsonSchema = Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
/** Validate and clone an untrusted TypeBox/JSON schema before embedding it in a child extension. */
|
|
16
|
+
export function validateOutputSchema(value: unknown): JsonSchema {
|
|
17
|
+
if (!isPlainObject(value)) throw new Error("outputSchema must be a plain JSON object.");
|
|
18
|
+
|
|
19
|
+
let nodes = 0;
|
|
20
|
+
const count = (depth: number): void => {
|
|
21
|
+
if (depth > MAX_SCHEMA_DEPTH) throw new Error(`outputSchema exceeds the maximum depth of ${MAX_SCHEMA_DEPTH}.`);
|
|
22
|
+
if (++nodes > MAX_SCHEMA_NODES) throw new Error(`outputSchema exceeds the maximum size of ${MAX_SCHEMA_NODES} values.`);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const visitJson = (item: unknown, depth: number): void => {
|
|
26
|
+
count(depth);
|
|
27
|
+
if (item === null || typeof item === "string" || typeof item === "boolean") return;
|
|
28
|
+
if (typeof item === "number" && Number.isFinite(item)) return;
|
|
29
|
+
if (Array.isArray(item)) {
|
|
30
|
+
for (const child of item) visitJson(child, depth + 1);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!isPlainObject(item)) throw new Error("outputSchema may contain only JSON values and plain objects.");
|
|
34
|
+
for (const [key, child] of Object.entries(item)) {
|
|
35
|
+
if (DANGEROUS_KEYS.has(key)) throw new Error(`outputSchema contains unsafe key "${key}".`);
|
|
36
|
+
visitJson(child, depth + 1);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const visitSchema = (item: unknown, depth: number, location: string): void => {
|
|
41
|
+
count(depth);
|
|
42
|
+
if (typeof item === "boolean") return;
|
|
43
|
+
if (!isPlainObject(item)) throw new Error(`outputSchema ${location} must be a schema object or boolean.`);
|
|
44
|
+
|
|
45
|
+
const type = item.type;
|
|
46
|
+
if (type !== undefined) {
|
|
47
|
+
const types = Array.isArray(type) ? type : [type];
|
|
48
|
+
if (types.length === 0 || types.some((entry) => typeof entry !== "string" || !JSON_SCHEMA_TYPES.has(entry))) {
|
|
49
|
+
throw new Error(`outputSchema ${location} has an invalid type keyword.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (item.required !== undefined && (!Array.isArray(item.required) || item.required.some((entry) => typeof entry !== "string"))) {
|
|
53
|
+
throw new Error(`outputSchema ${location} required must be an array of property names.`);
|
|
54
|
+
}
|
|
55
|
+
if (item.pattern !== undefined) {
|
|
56
|
+
if (typeof item.pattern !== "string") throw new Error(`outputSchema ${location} pattern must be a string.`);
|
|
57
|
+
try { new RegExp(item.pattern); } catch { throw new Error(`outputSchema ${location} has an invalid pattern.`); }
|
|
58
|
+
}
|
|
59
|
+
if (item.$ref !== undefined && (typeof item.$ref !== "string" || !item.$ref.startsWith("#/$defs/"))) {
|
|
60
|
+
throw new Error("outputSchema supports only local $ref values under #/$defs/.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const [key, child] of Object.entries(item)) {
|
|
64
|
+
if (DANGEROUS_KEYS.has(key)) throw new Error(`outputSchema contains unsafe key "${key}".`);
|
|
65
|
+
if (SCHEMA_MAP_KEYS.has(key)) {
|
|
66
|
+
if (!isPlainObject(child)) throw new Error(`outputSchema ${key} must be an object.`);
|
|
67
|
+
count(depth + 1);
|
|
68
|
+
for (const [name, schema] of Object.entries(child)) {
|
|
69
|
+
if (DANGEROUS_KEYS.has(name)) throw new Error(`outputSchema contains unsafe key "${name}".`);
|
|
70
|
+
visitSchema(schema, depth + 2, `${key}.${name}`);
|
|
71
|
+
}
|
|
72
|
+
} else if (SCHEMA_ARRAY_KEYS.has(key)) {
|
|
73
|
+
if (!Array.isArray(child) || child.length === 0) throw new Error(`outputSchema ${key} must be a non-empty array.`);
|
|
74
|
+
for (const schema of child) visitSchema(schema, depth + 1, key);
|
|
75
|
+
} else if (SCHEMA_VALUE_KEYS.has(key)) {
|
|
76
|
+
if (key === "items" && Array.isArray(child)) {
|
|
77
|
+
for (const schema of child) visitSchema(schema, depth + 1, key);
|
|
78
|
+
} else {
|
|
79
|
+
visitSchema(child, depth + 1, key);
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
// Annotation and assertion values (enum, const, defaults, numeric limits,
|
|
83
|
+
// custom extension keywords) are JSON data, not necessarily schemas.
|
|
84
|
+
visitJson(child, depth + 1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
visitSchema(value, 0, "root");
|
|
90
|
+
if (value.type !== "object") throw new Error('outputSchema must describe an object at its root (type: "object").');
|
|
91
|
+
const json = JSON.stringify(value);
|
|
92
|
+
if (Buffer.byteLength(json, "utf8") > MAX_SCHEMA_BYTES) {
|
|
93
|
+
throw new Error(`outputSchema exceeds the maximum encoded size of ${MAX_SCHEMA_BYTES} bytes.`);
|
|
94
|
+
}
|
|
95
|
+
return JSON.parse(json) as JsonSchema;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
99
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
100
|
+
const prototype = Object.getPrototypeOf(value);
|
|
101
|
+
return prototype === Object.prototype || prototype === null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function effectiveOutputSchema(taskSchema: unknown, rootSchema: unknown): JsonSchema | undefined {
|
|
105
|
+
const selected = taskSchema === undefined ? rootSchema : taskSchema;
|
|
106
|
+
return selected === undefined ? undefined : validateOutputSchema(selected);
|
|
107
|
+
}
|