@adibacsi/pi-jack 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,930 @@
1
+ /**
2
+ * JACK (JSON Agent Contractor Kit) — delegates tasks to isolated pi subprocesses with structured JSON output.
3
+ *
4
+ * Supports single task or batch execution with configurable concurrency.
5
+ *
6
+ * Modes:
7
+ * - Single: { task: "...", agent?: "...", system_prompt?: "...", ... }
8
+ * - Batch: { tasks: [{ task: "..." }, ...], run_mode: "sequential" | "parallel" }
9
+ *
10
+ * Child runs: pi --mode json -p --no-session --extension <this file> --json-schema <file>
11
+ * Without `agent`, the child runs as DEFAULT_AGENT (agents/worker.md), or a bare pi agent if that file is missing.
12
+ *
13
+ * Output contract (contract.ts, json-schema.ts): every child has a JSON Schema (the call's `schema`, else the agent's
14
+ * `schema` frontmatter, else DEFAULT_SCHEMA) and answers by calling the `jack_subagent_result` tool, whose parameters
15
+ * are that schema; it gives up with `jack_subagent_fail`. The child validates each answer and lets the model fix an
16
+ * invalid one; this side reads the outcome from the child's tool events and validates the answer once more.
17
+ */
18
+
19
+ import { spawn } from "node:child_process";
20
+ import * as fs from "node:fs";
21
+ import * as os from "node:os";
22
+ import * as path from "node:path";
23
+ import type { Message } from "@earendil-works/pi-ai";
24
+ import {
25
+ defineTool,
26
+ type ExtensionAPI,
27
+ withFileMutationQueue,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import { fileURLToPath } from "node:url";
30
+ import { Type, type TSchema } from "typebox";
31
+ import {
32
+ type AgentConfig,
33
+ type AgentLoadError,
34
+ type AgentSource,
35
+ discoverAgents,
36
+ } from "./agents.ts";
37
+ import { registerJsonSchema } from "./json-schema.ts";
38
+ import {
39
+ DEFAULT_SCHEMA,
40
+ FAIL_TOOL,
41
+ MAX_FORMAT_RETRIES,
42
+ RESULT_TOOL,
43
+ resolveSchema,
44
+ SCHEMA_FLAG,
45
+ THINKING_LEVELS,
46
+ type ThinkingLevel,
47
+ schemaErrors,
48
+ } from "./contract.ts";
49
+
50
+ export const MAX_PARALLEL = 2;
51
+
52
+ /**
53
+ * This extension, loaded into every child for its `--json-schema` flag. Pi loads an extension path only once, so
54
+ * this is a no-op where pi already discovers it on its own.
55
+ */
56
+ const THIS_EXTENSION = fileURLToPath(import.meta.url);
57
+
58
+ /** Agent used when a call omits `agent`. */
59
+ export const DEFAULT_AGENT = "worker";
60
+
61
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
62
+ const currentScript = process.argv[1];
63
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
64
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
65
+ return { command: process.execPath, args: [currentScript, ...args] };
66
+ }
67
+
68
+ const execName = path.basename(process.execPath).toLowerCase();
69
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
70
+ if (!isGenericRuntime) {
71
+ return { command: process.execPath, args };
72
+ }
73
+
74
+ return { command: "pi", args };
75
+ }
76
+
77
+ /** Writes the child's schema and, when there is one, its system prompt into a fresh private temp dir. */
78
+ async function writeTempFiles(
79
+ agentName: string,
80
+ schema: TSchema,
81
+ prompt: string,
82
+ ): Promise<{ dir: string; schemaPath: string; promptPath?: string }> {
83
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-jack-"));
84
+ const write = (filePath: string, text: string) =>
85
+ withFileMutationQueue(filePath, () =>
86
+ fs.promises.writeFile(filePath, text, { encoding: "utf-8", mode: 0o600 }),
87
+ );
88
+
89
+ const schemaPath = path.join(dir, "schema.json");
90
+ await write(schemaPath, JSON.stringify(schema));
91
+ if (!prompt) return { dir, schemaPath };
92
+
93
+ const promptPath = path.join(
94
+ dir,
95
+ `prompt-${agentName.replace(/[^\w.-]+/g, "_")}.md`,
96
+ );
97
+ await write(promptPath, prompt);
98
+ return { dir, schemaPath, promptPath };
99
+ }
100
+
101
+ export function normalizeTools(value: unknown): string[] | undefined {
102
+ const raw = Array.isArray(value)
103
+ ? value
104
+ : typeof value === "string"
105
+ ? value.split(",")
106
+ : [];
107
+ const tools = raw
108
+ .filter((t): t is string => typeof t === "string")
109
+ .map((t) => t.trim())
110
+ .filter(Boolean);
111
+ return tools.length > 0 ? tools : undefined;
112
+ }
113
+
114
+ interface SubagentUsage {
115
+ turns: number;
116
+ input: number;
117
+ output: number;
118
+ cacheRead: number;
119
+ cacheWrite: number;
120
+ cost: number;
121
+ }
122
+
123
+ interface SingleResult {
124
+ success: boolean;
125
+ /** Whether `data` holds an answer the subagent submitted. */
126
+ parsed: boolean;
127
+ data: any;
128
+ raw: string;
129
+ agent: string;
130
+ task: string;
131
+ error?: string;
132
+ /** How many answers the subagent submitted with `jack_subagent_result`, valid or not. */
133
+ attempts: number;
134
+ usage: SubagentUsage;
135
+ /** The model and thinking level the subagent's last turn actually ran with, from its messages. */
136
+ ranWith?: RanWith;
137
+ }
138
+
139
+ interface RanWith {
140
+ model: string;
141
+ thinking?: string;
142
+ }
143
+
144
+ /** How a subagent was set up once defaults and overrides were applied; shown by `debug_mode`. */
145
+ export interface SubagentSetup {
146
+ agent: string;
147
+ /** Where the agent file came from; undefined for a bare pi agent (no default agent file). */
148
+ agentSource?: AgentSource;
149
+ overridesBuiltIn?: boolean;
150
+ /** Which prompts make up the system prompt: the agent's, the call's `system_prompt`, or both. */
151
+ prompt: Array<"agent" | "call">;
152
+ model?: string;
153
+ modelFrom?: "call" | "agent";
154
+ /** The requested level; pi moves an unsupported one to the nearest level the model supports. */
155
+ thinking?: ThinkingLevel;
156
+ thinkingFrom?: "call" | "agent";
157
+ /** The `--tools` allowlist given to the child, result tools included; undefined means pi's default tools. */
158
+ tools?: string[];
159
+ toolsFrom?: "call" | "agent";
160
+ schema: TSchema;
161
+ schemaFrom: "call" | "agent" | "default";
162
+ /** The child's command line, as spawned. Its temp files are deleted once the child exits. */
163
+ command: string[];
164
+ }
165
+
166
+ const shellQuote = (arg: string) =>
167
+ /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, "'\\''")}'`;
168
+
169
+ /** Renders a setup as indented lines, for the progress display and the result. */
170
+ export function describeSetup(setup: SubagentSetup): string {
171
+ const from = (source: string | undefined) =>
172
+ source ? ` (from the ${source})` : "";
173
+ const agent = !setup.agentSource
174
+ ? `${setup.agent} (no agent file, bare pi)`
175
+ : `${setup.agent} (${setup.agentSource === "built-in" ? "built-in" : setup.overridesBuiltIn ? "yours, overrides built-in" : "yours"})`;
176
+ const appended = setup.prompt.map((p) =>
177
+ p === "agent" ? "the agent's" : "the call's system_prompt",
178
+ );
179
+ const prompt =
180
+ appended.length > 0 ? `pi's, plus ${appended.join(" and ")}` : "pi's only";
181
+ return [
182
+ `agent: ${agent}`,
183
+ `system prompt: ${prompt}`,
184
+ `model: ${setup.model ? `${setup.model}${from(setup.modelFrom)}` : "pi's default"}`,
185
+ `thinking: ${setup.thinking ? `${setup.thinking}${from(setup.thinkingFrom)}, or the nearest level the model supports` : "pi's default"}`,
186
+ `tools: ${setup.tools ? `${setup.tools.join(", ")}${from(setup.toolsFrom)}` : `pi's default, plus ${RESULT_TOOL}, ${FAIL_TOOL}`}`,
187
+ `schema (${setup.schemaFrom === "default" ? "the default" : `from the ${setup.schemaFrom}`}): ${JSON.stringify(setup.schema)}`,
188
+ `command: ${setup.command.map(shellQuote).join(" ")}`,
189
+ ]
190
+ .map((line) => ` ${line}`)
191
+ .join("\n");
192
+ }
193
+
194
+ const thinkingSchema = (description: string) =>
195
+ Type.Optional(
196
+ Type.Union(
197
+ THINKING_LEVELS.map((level) => Type.Literal(level)),
198
+ { description },
199
+ ),
200
+ );
201
+
202
+ const taskItemSchema = Type.Object({
203
+ task: Type.String({ description: "Task description for this subagent" }),
204
+ agent: Type.Optional(
205
+ Type.String({
206
+ description:
207
+ "Optional. Name of an available agent; defaults to the top-level `agent`, if any",
208
+ }),
209
+ ),
210
+ system_prompt: Type.Optional(
211
+ Type.String({ description: "System prompt override for this task" }),
212
+ ),
213
+ tools: Type.Optional(
214
+ Type.Union([Type.String(), Type.Array(Type.String())], {
215
+ description: "Tools for this task",
216
+ }),
217
+ ),
218
+ model: Type.Optional(
219
+ Type.String({ description: "Model override for this task" }),
220
+ ),
221
+ thinking: thinkingSchema("Thinking level override for this task"),
222
+ });
223
+
224
+ const outputSchema = Type.Object({
225
+ results: Type.Array(
226
+ Type.Object({
227
+ success: Type.Boolean(),
228
+ parsed: Type.Boolean(),
229
+ data: Type.Any(),
230
+ raw: Type.String(),
231
+ agent: Type.String(),
232
+ task: Type.String(),
233
+ error: Type.Optional(Type.String()),
234
+ attempts: Type.Number(),
235
+ ranWith: Type.Optional(
236
+ Type.Object({
237
+ model: Type.String(),
238
+ thinking: Type.Optional(Type.String()),
239
+ }),
240
+ ),
241
+ usage: Type.Object({
242
+ turns: Type.Number(),
243
+ input: Type.Number(),
244
+ output: Type.Number(),
245
+ cacheRead: Type.Number(),
246
+ cacheWrite: Type.Number(),
247
+ cost: Type.Number(),
248
+ }),
249
+ }),
250
+ ),
251
+ });
252
+
253
+ const zeroUsage = (): SubagentUsage => ({
254
+ turns: 0,
255
+ input: 0,
256
+ output: 0,
257
+ cacheRead: 0,
258
+ cacheWrite: 0,
259
+ cost: 0,
260
+ });
261
+
262
+ async function runSingleSubagent(
263
+ taskText: string,
264
+ agentNameInput: string | undefined,
265
+ systemPromptInput: string | undefined,
266
+ toolsInput: unknown,
267
+ modelInput: string | undefined,
268
+ thinkingInput: ThinkingLevel | undefined,
269
+ schemaInput: unknown,
270
+ signal: AbortSignal | undefined,
271
+ onProgress?: (turns: number, activity: string) => void,
272
+ onSetup?: (setup: SubagentSetup) => void,
273
+ ): Promise<SingleResult> {
274
+ let agentPrompt = "";
275
+ let extraPrompt: string | undefined;
276
+ let tools: string[] | undefined;
277
+ let model: string | undefined;
278
+ let toolsFrom: SubagentSetup["toolsFrom"];
279
+ let thinking: ThinkingLevel | undefined;
280
+ let resolvedAgentName = agentNameInput ?? "direct";
281
+ // A schema passed on the call wins over the agent's default. Each resolves relative paths from where it was
282
+ // written: the call from the working directory, the frontmatter from the agent file's folder.
283
+ let schemaSource: unknown = schemaInput;
284
+ let schemaBaseDir = process.cwd();
285
+
286
+ const failure = (error: string): SingleResult => ({
287
+ success: false,
288
+ parsed: false,
289
+ data: null,
290
+ raw: "",
291
+ agent: resolvedAgentName,
292
+ task: taskText,
293
+ error,
294
+ attempts: 0,
295
+ usage: zeroUsage(),
296
+ });
297
+
298
+ const { agents, errors: loadErrors } = discoverAgents();
299
+ const wanted = agentNameInput ?? DEFAULT_AGENT;
300
+ const agent = agents.find((a) => a.name === wanted);
301
+ // A broken file for the wanted agent must not quietly turn into another agent: the built-in one it was meant to
302
+ // override, or, for the default agent, a bare pi agent.
303
+ const brokenFile = loadErrors.find(
304
+ (e) => e.file === `${wanted}.md` && (e.source === "user" || !agent),
305
+ );
306
+ if (brokenFile) {
307
+ const which = agentNameInput ? "agent" : "default agent";
308
+ return failure(
309
+ `The ${which} file ${brokenFile.file} could not be loaded: ${brokenFile.message}`,
310
+ );
311
+ }
312
+ if (agentNameInput) {
313
+ if (!agent) {
314
+ const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
315
+ const broken =
316
+ loadErrors.length > 0
317
+ ? ` Agent files that failed to load: ${describeLoadErrors(loadErrors)}.`
318
+ : "";
319
+ return failure(
320
+ `Unknown agent: "${agentNameInput}". Available: ${available}.${broken} ` +
321
+ "Omit `agent` to run the default subagent.",
322
+ );
323
+ }
324
+ // A named agent owns its prompt and tools; the model is only a default, so a call can run it on another one.
325
+ agentPrompt = agent.systemPrompt;
326
+ tools = agent.tools;
327
+ model = modelInput ?? agent.model;
328
+ thinking = thinkingInput ?? agent.thinking;
329
+ toolsFrom = tools ? "agent" : undefined;
330
+ } else {
331
+ // The default agent is the base; call-level system_prompt is appended, tools/model/schema override it.
332
+ agentPrompt = agent?.systemPrompt ?? "";
333
+ extraPrompt = systemPromptInput;
334
+ const callTools = normalizeTools(toolsInput);
335
+ tools = callTools ?? agent?.tools;
336
+ model = modelInput ?? agent?.model;
337
+ thinking = thinkingInput ?? agent?.thinking;
338
+ toolsFrom = callTools ? "call" : tools ? "agent" : undefined;
339
+ }
340
+ if (agent) resolvedAgentName = agent.name;
341
+ if (schemaSource === undefined && agent?.schema !== undefined) {
342
+ schemaSource = agent.schema;
343
+ schemaBaseDir = agent.dir;
344
+ }
345
+
346
+ let schema: TSchema;
347
+ try {
348
+ schema = resolveSchema(schemaSource ?? DEFAULT_SCHEMA, schemaBaseDir);
349
+ } catch (e) {
350
+ return failure(`Invalid schema: ${e instanceof Error ? e.message : e}`);
351
+ }
352
+
353
+ const systemPrompt = [agentPrompt, extraPrompt]
354
+ .filter((p) => p?.trim())
355
+ .map((p) => p!.trim())
356
+ .join("\n\n");
357
+
358
+ let tmpDir: string | null = null;
359
+
360
+ try {
361
+ const tmp = await writeTempFiles(resolvedAgentName, schema, systemPrompt);
362
+ tmpDir = tmp.dir;
363
+
364
+ const args: string[] = [
365
+ "--mode",
366
+ "json",
367
+ "-p",
368
+ "--no-session",
369
+ "--exclude-tools",
370
+ "jack",
371
+ ];
372
+ args.push(
373
+ "--extension",
374
+ THIS_EXTENSION,
375
+ `--${SCHEMA_FLAG}`,
376
+ tmp.schemaPath,
377
+ );
378
+ if (model) args.push("--model", model);
379
+ if (thinking) args.push("--thinking", thinking);
380
+ // `--tools` is a complete allowlist, so the result tools must be on it or the child cannot answer.
381
+ if (tools && tools.length > 0)
382
+ args.push(
383
+ "--tools",
384
+ [...new Set([...tools, RESULT_TOOL, FAIL_TOOL])].join(","),
385
+ );
386
+ if (tmp.promptPath) args.push("--append-system-prompt", tmp.promptPath);
387
+ args.push(taskText);
388
+
389
+ if (onSetup) {
390
+ const invocation = getPiInvocation(args);
391
+ const toolsFlag = args.indexOf("--tools");
392
+ onSetup({
393
+ agent: resolvedAgentName,
394
+ agentSource: agent?.source,
395
+ overridesBuiltIn: agent?.overridesBuiltIn,
396
+ prompt: [
397
+ ...(agentPrompt.trim() ? ["agent" as const] : []),
398
+ ...(extraPrompt?.trim() ? ["call" as const] : []),
399
+ ],
400
+ model,
401
+ modelFrom: modelInput ? "call" : model ? "agent" : undefined,
402
+ thinking,
403
+ thinkingFrom: thinkingInput ? "call" : thinking ? "agent" : undefined,
404
+ tools: toolsFlag >= 0 ? args[toolsFlag + 1].split(",") : undefined,
405
+ toolsFrom,
406
+ schema,
407
+ schemaFrom:
408
+ schemaInput !== undefined
409
+ ? "call"
410
+ : schemaSource !== undefined
411
+ ? "agent"
412
+ : "default",
413
+ command: [invocation.command, ...invocation.args],
414
+ });
415
+ }
416
+
417
+ const usage = zeroUsage();
418
+ const run = await runChild(args, usage, signal, onProgress);
419
+ const result = (
420
+ error: string | undefined,
421
+ data: unknown = null,
422
+ ): SingleResult => ({
423
+ success: !error,
424
+ parsed: data !== null,
425
+ data,
426
+ raw: run.raw,
427
+ agent: resolvedAgentName,
428
+ task: taskText,
429
+ error,
430
+ attempts: run.submissions,
431
+ usage,
432
+ ranWith: run.ranWith,
433
+ });
434
+
435
+ if (run.retriesExhausted) {
436
+ return result(
437
+ `No valid answer after ${run.submissions} attempts. ${run.lastRejection}`,
438
+ );
439
+ }
440
+ if (run.exitCode !== 0)
441
+ return result(
442
+ run.stderr || `Exit code ${run.exitCode}`,
443
+ run.answer ?? null,
444
+ );
445
+ if (run.failReason !== undefined) return result(run.failReason);
446
+ if (run.answer !== undefined) {
447
+ // The child validated it already; checking again here guards against a child that skipped or broke that step.
448
+ const errors = schemaErrors(schema, run.answer);
449
+ return result(
450
+ errors.length > 0
451
+ ? `The answer does not match the schema: ${errors.join("; ")}`
452
+ : undefined,
453
+ run.answer,
454
+ );
455
+ }
456
+ if (run.lastRejection) {
457
+ return result(
458
+ `No valid answer after ${run.submissions} attempts. ${run.lastRejection}`,
459
+ );
460
+ }
461
+ return result(`The subagent finished without calling ${RESULT_TOOL}.`);
462
+ } finally {
463
+ if (tmpDir) {
464
+ try {
465
+ await fs.promises.rm(tmpDir, { recursive: true, force: true });
466
+ } catch {
467
+ // ignore
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ interface ChildRun {
474
+ exitCode: number;
475
+ stderr: string;
476
+ /** The child's final assistant text, or "" when it gave none. Informational only; the answer is `answer`. */
477
+ raw: string;
478
+ /** Arguments of the last accepted `jack_subagent_result` call. */
479
+ answer?: unknown;
480
+ /** Reason given to `jack_subagent_fail`, if the child gave up. */
481
+ failReason?: string;
482
+ /** Number of `jack_subagent_result` calls, accepted or not. */
483
+ submissions: number;
484
+ /** Model and thinking level of the last assistant turn. */
485
+ ranWith?: RanWith;
486
+ /** What the child said about the last rejected `jack_subagent_result` call. */
487
+ lastRejection?: string;
488
+ /** Set when the child was stopped for using up MAX_FORMAT_RETRIES. */
489
+ retriesExhausted?: boolean;
490
+ }
491
+
492
+ /** Runs one pi child to completion, adding its turns and token usage to `usage`. */
493
+ async function runChild(
494
+ args: string[],
495
+ usage: SubagentUsage,
496
+ signal: AbortSignal | undefined,
497
+ onProgress?: (turns: number, activity: string) => void,
498
+ ): Promise<ChildRun> {
499
+ const invocation = getPiInvocation(args);
500
+ const proc = spawn(invocation.command, invocation.args, {
501
+ cwd: process.cwd(),
502
+ shell: false,
503
+ stdio: ["ignore", "pipe", "pipe"],
504
+ });
505
+
506
+ const run: ChildRun = { exitCode: 1, stderr: "", raw: "", submissions: 0 };
507
+ const messages: Message[] = [];
508
+ let rejections = 0;
509
+ let buffer = "";
510
+
511
+ const handleEvent = (event: any) => {
512
+ if (event.type === "message_end" && event.message) {
513
+ const msg = event.message as Message;
514
+ messages.push(msg);
515
+ if (msg.role === "assistant") {
516
+ usage.turns++;
517
+ run.ranWith = {
518
+ model: `${msg.provider}/${msg.model}`,
519
+ thinking: msg.thinkingLevel,
520
+ };
521
+ const u = msg.usage;
522
+ if (u) {
523
+ usage.input += u.input ?? 0;
524
+ usage.output += u.output ?? 0;
525
+ usage.cacheRead += u.cacheRead ?? 0;
526
+ usage.cacheWrite += u.cacheWrite ?? 0;
527
+ usage.cost += u.cost?.total ?? 0;
528
+ }
529
+ onProgress?.(usage.turns, "thinking");
530
+ }
531
+ } else if (event.type === "tool_execution_start") {
532
+ const arg = event.args?.command ?? event.args?.path ?? "";
533
+ onProgress?.(
534
+ usage.turns,
535
+ `${event.toolName}${arg ? ` ${String(arg).split("\n")[0].slice(0, 60)}` : ""}`,
536
+ );
537
+ } else if (
538
+ event.type === "tool_execution_end" &&
539
+ event.toolName === RESULT_TOOL
540
+ ) {
541
+ run.submissions++;
542
+ if (!event.isError) {
543
+ run.answer = event.result?.details;
544
+ return;
545
+ }
546
+ // Rejections come from pi's own argument validation as well as json-schema.ts, so the cap is enforced here.
547
+ run.lastRejection =
548
+ event.result?.content?.[0]?.text ?? "The answer was rejected.";
549
+ rejections++;
550
+ if (rejections > MAX_FORMAT_RETRIES && !run.retriesExhausted) {
551
+ run.retriesExhausted = true;
552
+ proc.kill("SIGTERM");
553
+ }
554
+ } else if (
555
+ event.type === "tool_execution_end" &&
556
+ event.toolName === FAIL_TOOL &&
557
+ !event.isError
558
+ ) {
559
+ run.failReason = String(
560
+ event.result?.details?.reason ??
561
+ "The subagent gave up without a reason.",
562
+ );
563
+ }
564
+ };
565
+
566
+ run.exitCode = await new Promise<number>((resolve, reject) => {
567
+ proc.stdout.on("data", (data) => {
568
+ buffer += data.toString();
569
+ const lines = buffer.split("\n");
570
+ buffer = lines.pop() ?? "";
571
+ for (const line of lines) {
572
+ if (!line.trim()) continue;
573
+ try {
574
+ handleEvent(JSON.parse(line));
575
+ } catch {
576
+ // not a JSON event line
577
+ }
578
+ }
579
+ });
580
+
581
+ proc.stderr.on("data", (data) => {
582
+ run.stderr += data.toString();
583
+ });
584
+
585
+ proc.on("error", (err) => reject(err));
586
+ proc.on("close", (code) => resolve(code ?? 1));
587
+
588
+ signal?.addEventListener("abort", () => {
589
+ proc.kill("SIGTERM");
590
+ });
591
+ });
592
+
593
+ run.stderr = run.stderr.trim();
594
+ run.raw = getFinalAssistantText(messages) ?? "";
595
+ return run;
596
+ }
597
+
598
+ export async function mapWithLimit<TIn, TOut>(
599
+ items: TIn[],
600
+ concurrency: number,
601
+ fn: (item: TIn, index: number) => Promise<TOut>,
602
+ ): Promise<TOut[]> {
603
+ if (items.length === 0) return [];
604
+ const limit = Math.max(1, Math.min(concurrency, items.length));
605
+ const results: TOut[] = new Array(items.length);
606
+ let nextIndex = 0;
607
+ const workers = Array.from({ length: limit }).map(async () => {
608
+ while (true) {
609
+ const current = nextIndex++;
610
+ if (current >= items.length) return;
611
+ results[current] = await fn(items[current], current);
612
+ }
613
+ });
614
+ await Promise.all(workers);
615
+ return results;
616
+ }
617
+
618
+ const jackTool = defineTool({
619
+ name: "jack",
620
+ label: "JACK",
621
+ description:
622
+ "JACK (JSON Agent Contractor Kit): delegate tasks to subagents, isolated pi subprocesses that answer in a JSON Schema.\n" +
623
+ "Single task: pass `task`.\n" +
624
+ "Batch: pass `tasks` array with `run_mode: 'sequential' (default) or 'parallel'`.\n" +
625
+ `No agent (the usual case): omit \`agent\`; the subagent is the default \`${DEFAULT_AGENT}\` agent, ` +
626
+ "optionally customized with `system_prompt` (appended), `tools`, `model`.\n" +
627
+ "Named agent: pass `agent` only with one of the names listed below; never invent one. It keeps its own " +
628
+ "prompt and tools; `model` and `thinking` still override the agent's.",
629
+ parameters: Type.Object({
630
+ // Single task (backward compatible)
631
+ task: Type.Optional(
632
+ Type.String({ description: "Single task description" }),
633
+ ),
634
+
635
+ // Batch tasks
636
+ tasks: Type.Optional(
637
+ Type.Array(taskItemSchema, {
638
+ description:
639
+ "Multiple tasks to run. Each item inherits missing fields from the top-level params.",
640
+ }),
641
+ ),
642
+
643
+ // Run mode for batch
644
+ run_mode: Type.Optional(
645
+ Type.Union([Type.Literal("sequential"), Type.Literal("parallel")], {
646
+ description: "How to execute `tasks`. Default: sequential.",
647
+ }),
648
+ ),
649
+
650
+ // Global defaults (used when tasks[] items don't specify their own)
651
+ agent: Type.Optional(
652
+ Type.String({
653
+ description:
654
+ "Optional. Name of an available agent (see tool description); omit otherwise. Inherited by batch items.",
655
+ }),
656
+ ),
657
+ system_prompt: Type.Optional(
658
+ Type.String({
659
+ description: "Default system prompt. Inherited by batch items.",
660
+ }),
661
+ ),
662
+ tools: Type.Optional(
663
+ Type.Union([Type.String(), Type.Array(Type.String())], {
664
+ description: "Default tools. Inherited by batch items.",
665
+ }),
666
+ ),
667
+ debug_mode: Type.Optional(
668
+ Type.Boolean({
669
+ description:
670
+ "Show how each subagent is set up: agent, system prompt, model, thinking, tools, schema, the pi command " +
671
+ "line, and the model and thinking level it ran with. Only set this when the user asks for it.",
672
+ }),
673
+ ),
674
+ model: Type.Optional(
675
+ Type.String({
676
+ description:
677
+ "Model for the subagents, as `provider/id` or a pattern (as for `pi --model`); overrides a named " +
678
+ "agent's model. Inherited by batch items.",
679
+ }),
680
+ ),
681
+ thinking: thinkingSchema(
682
+ "Thinking level for the subagents; overrides a named agent's. When the model doesn't support it, the nearest " +
683
+ "higher level it supports is used, else the nearest lower one. Inherited by batch items.",
684
+ ),
685
+
686
+ // Schema for JSON output
687
+ schema: Type.Optional(
688
+ Type.Union([Type.String(), Type.Record(Type.String(), Type.Unknown())], {
689
+ description:
690
+ "Optional JSON Schema the answer must conform to: an object, inline JSON, or a path to a .json file " +
691
+ "(relative to the working directory). The root must describe an object; give each property a " +
692
+ "`description`, which is how the subagent learns what to put there. The subagent's validated answer " +
693
+ "is returned as `data`. Defaults to the agent's `schema` frontmatter, else " +
694
+ `\`${JSON.stringify(DEFAULT_SCHEMA)}\`.`,
695
+ }),
696
+ ),
697
+ }),
698
+ outputSchema,
699
+
700
+ async execute(_toolCallId, params, signal, onUpdate) {
701
+ // Build normalized task list
702
+ const taskItems: Array<{
703
+ task: string;
704
+ agent?: string;
705
+ system_prompt?: string;
706
+ tools?: unknown;
707
+ model?: string;
708
+ thinking?: ThinkingLevel;
709
+ }> = [];
710
+
711
+ if (params.tasks && params.tasks.length > 0) {
712
+ for (const item of params.tasks) {
713
+ taskItems.push({
714
+ task: item.task,
715
+ agent: item.agent ?? params.agent,
716
+ system_prompt: item.system_prompt ?? params.system_prompt,
717
+ tools: item.tools ?? params.tools,
718
+ model: item.model ?? params.model,
719
+ thinking: item.thinking ?? params.thinking,
720
+ });
721
+ }
722
+ } else if (params.task) {
723
+ taskItems.push({
724
+ task: params.task,
725
+ agent: params.agent,
726
+ system_prompt: params.system_prompt,
727
+ tools: params.tools,
728
+ model: params.model,
729
+ thinking: params.thinking,
730
+ });
731
+ } else {
732
+ return {
733
+ content: [
734
+ { type: "text", text: "Either `task` or `tasks` must be provided." },
735
+ ],
736
+ structuredContent: {
737
+ results: [
738
+ {
739
+ success: false,
740
+ parsed: false,
741
+ data: null,
742
+ raw: "",
743
+ agent: params.agent ?? "direct",
744
+ task: "",
745
+ error: "Either `task` or `tasks` must be provided.",
746
+ attempts: 0,
747
+ usage: {
748
+ turns: 0,
749
+ input: 0,
750
+ output: 0,
751
+ cacheRead: 0,
752
+ cacheWrite: 0,
753
+ cost: 0,
754
+ },
755
+ },
756
+ ],
757
+ } as any,
758
+ details: { error: "Missing task or tasks parameter" } as any,
759
+ isError: true,
760
+ };
761
+ }
762
+
763
+ const runMode = params.run_mode ?? "sequential";
764
+ const concurrency = runMode === "parallel" ? MAX_PARALLEL : 1;
765
+
766
+ // Live per-task status, streamed to the TUI so long batches don't look frozen.
767
+ const status = taskItems.map(() => "queued");
768
+ // With debug_mode, each task's setup, filled in as its child starts.
769
+ const setups: Array<SubagentSetup | undefined> = taskItems.map(
770
+ () => undefined,
771
+ );
772
+ const debug = params.debug_mode === true;
773
+ const startedAt = Date.now();
774
+ const reportProgress = () => {
775
+ const done = status.filter(
776
+ (s) => s.startsWith("✓") || s.startsWith("✗"),
777
+ ).length;
778
+ const secs = Math.round((Date.now() - startedAt) / 1000);
779
+ const lines = taskItems.map((item, i) => {
780
+ const line = `[${i + 1}] ${taskLabel(item.task)} — ${status[i]}`;
781
+ return debug && setups[i]
782
+ ? `${line}\n${describeSetup(setups[i])}`
783
+ : line;
784
+ });
785
+ onUpdate?.({
786
+ content: [
787
+ {
788
+ type: "text",
789
+ text: `${done}/${taskItems.length} done (${secs}s)\n${lines.join("\n")}`,
790
+ },
791
+ ],
792
+ details: undefined as any,
793
+ });
794
+ };
795
+ reportProgress();
796
+
797
+ const results = await mapWithLimit(
798
+ taskItems,
799
+ concurrency,
800
+ async (item, i) => {
801
+ status[i] = "starting";
802
+ reportProgress();
803
+ const result = await runSingleSubagent(
804
+ item.task,
805
+ item.agent,
806
+ item.system_prompt,
807
+ item.tools,
808
+ item.model,
809
+ item.thinking,
810
+ params.schema,
811
+ signal,
812
+ (turns, activity) => {
813
+ status[i] = `turn ${turns + 1}: ${activity}`;
814
+ reportProgress();
815
+ },
816
+ debug
817
+ ? (setup) => {
818
+ setups[i] = setup;
819
+ reportProgress();
820
+ }
821
+ : undefined,
822
+ );
823
+ status[i] = `${result.success ? "✓" : "✗"} ${result.usage.turns} turns`;
824
+ reportProgress();
825
+ return result;
826
+ },
827
+ );
828
+
829
+ const anyFailed = results.some((r) => !r.success);
830
+
831
+ // The model only receives `content` (structuredContent is for programmatic callers),
832
+ // so each child's full reply must be included here, not just a status line.
833
+ const blocks = results.map((r, i) => {
834
+ const prefix = results.length > 1 ? `[${i + 1}/${results.length}] ` : "";
835
+ const retried = r.attempts > 1 ? ` (${r.attempts} attempts)` : "";
836
+ const header = r.success
837
+ ? `${prefix}✓ ${r.agent}: ${taskLabel(r.task)}${retried}`
838
+ : `${prefix}✗ ${r.agent}: ${taskLabel(r.task)}${retried}\nError: ${r.error ?? "failed"}`;
839
+ return r.data !== null
840
+ ? `${header}\n${JSON.stringify(r.data, null, 2)}`
841
+ : header;
842
+ });
843
+
844
+ if (debug) {
845
+ const described = taskItems.map(
846
+ (item, i) =>
847
+ `[${i + 1}] ${taskLabel(item.task)}\n${setups[i] ? describeSetup(setups[i]) : " (no child was started)"}` +
848
+ (results[i].ranWith
849
+ ? `\n ran with: ${results[i].ranWith.model}, thinking ${results[i].ranWith.thinking ?? "unknown"}`
850
+ : ""),
851
+ );
852
+ blocks.unshift(
853
+ `Debug: how each subagent was set up\n${described.join("\n")}`,
854
+ );
855
+ }
856
+
857
+ return {
858
+ content: [{ type: "text", text: blocks.join("\n\n") }],
859
+ structuredContent: { results } as any,
860
+ details: {
861
+ results,
862
+ runMode,
863
+ count: results.length,
864
+ ...(debug ? { setups } : {}),
865
+ } as any,
866
+ isError: anyFailed,
867
+ };
868
+ },
869
+ });
870
+
871
+ export function taskLabel(task: string): string {
872
+ const firstLine = task.split("\n")[0];
873
+ return `${firstLine.slice(0, 60)}${firstLine.length > 60 || firstLine !== task ? "..." : ""}`;
874
+ }
875
+
876
+ export function getFinalAssistantText(messages: Message[]): string | undefined {
877
+ for (let i = messages.length - 1; i >= 0; i--) {
878
+ const msg = messages[i];
879
+ if (msg.role === "assistant") {
880
+ for (const part of msg.content) {
881
+ if (part.type === "text") return part.text;
882
+ }
883
+ }
884
+ }
885
+ return undefined;
886
+ }
887
+
888
+ function describeLoadErrors(errors: AgentLoadError[]): string {
889
+ return errors
890
+ .map(
891
+ (e) =>
892
+ `${e.file}${e.source === "built-in" ? " [built-in]" : ""} (${e.message})`,
893
+ )
894
+ .join("; ");
895
+ }
896
+
897
+ function describeAgent(agent: AgentConfig): string {
898
+ const origin = agent.overridesBuiltIn
899
+ ? " (yours, overrides built-in)"
900
+ : agent.source === "built-in"
901
+ ? " (built-in)"
902
+ : "";
903
+ return `- ${agent.name}${origin}: ${agent.description}`;
904
+ }
905
+
906
+ export default function (pi: ExtensionAPI) {
907
+ registerJsonSchema(pi);
908
+ // The prompt templates in prompts/ are declared by the `pi.prompts` manifest entry instead of a
909
+ // `resources_discover` handler: declaring them lets users filter them off with `"prompts": []` in settings, which
910
+ // cannot reach an extension-declared path. Declaring both would load every template twice, since Pi dedupes
911
+ // prompts by name, not by path.
912
+
913
+ // List the agents known at load time so the model never has to guess a name.
914
+ const { agents, errors } = discoverAgents();
915
+ const available =
916
+ agents.length > 0
917
+ ? `Available agents:\n${agents.map(describeAgent).join("\n")}`
918
+ : "Available agents: none (always omit `agent`).";
919
+ pi.registerTool({
920
+ ...jackTool,
921
+ description: `${jackTool.description}\n${available}`,
922
+ });
923
+
924
+ if (errors.length > 0) {
925
+ const message = `[jack] Agent files that failed to load: ${describeLoadErrors(errors)}`;
926
+ pi.on("session_start", (_event, ctx) => {
927
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
928
+ });
929
+ }
930
+ }