@herbertgao/pi-subagents 0.17.0 → 0.18.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +427 -120
  3. package/docs/rpc.md +184 -0
  4. package/docs/workflows.md +466 -0
  5. package/examples/agent-tool-description.md +6 -6
  6. package/examples/workflows/compose.js +52 -0
  7. package/examples/workflows/fan-out-audit.js +56 -0
  8. package/examples/workflows/gated-fix.js +60 -0
  9. package/examples/workflows/lib/count-child.js +30 -0
  10. package/examples/workflows/review-panel.js +68 -0
  11. package/examples/workflows/structured-findings.js +81 -0
  12. package/package.json +12 -9
  13. package/src/agent-file-toggle.ts +52 -12
  14. package/src/agent-manager.ts +837 -146
  15. package/src/agent-runner.ts +213 -39
  16. package/src/cross-extension-rpc.ts +73 -14
  17. package/src/custom-agents.ts +101 -47
  18. package/src/index.ts +2249 -914
  19. package/src/invocation-config.ts +13 -0
  20. package/src/mention-clone.ts +215 -0
  21. package/src/mention.ts +147 -0
  22. package/src/model-resolver.ts +9 -1
  23. package/src/nested-tools.ts +40 -26
  24. package/src/output-file.ts +18 -8
  25. package/src/prompts.ts +46 -9
  26. package/src/schedule.ts +21 -16
  27. package/src/settings.ts +137 -7
  28. package/src/structured-output.ts +136 -0
  29. package/src/types.ts +126 -8
  30. package/src/ui/agent-mention.ts +274 -0
  31. package/src/ui/agent-widget.ts +20 -5
  32. package/src/ui/conversation-viewer.ts +14 -1
  33. package/src/ui/fleet-list.ts +167 -22
  34. package/src/ui/workflow-card.ts +555 -0
  35. package/src/ui/workflow-dialog.ts +1304 -0
  36. package/src/ui/workflow-menu.ts +226 -0
  37. package/src/workflow/collisions.ts +122 -0
  38. package/src/workflow/entry.ts +47 -0
  39. package/src/workflow/host.ts +463 -0
  40. package/src/workflow/journal.ts +164 -0
  41. package/src/workflow/json-schema.ts +142 -0
  42. package/src/workflow/meta.ts +401 -0
  43. package/src/workflow/progress.ts +622 -0
  44. package/src/workflow/runtime.ts +1399 -0
  45. package/src/workflow/saved.ts +230 -0
  46. package/src/workflow/task.ts +333 -0
  47. package/src/workflow/tool-description.ts +200 -0
  48. package/src/workflow/worker-source.ts +781 -0
  49. package/src/worktree.ts +97 -95
  50. package/src/xml.ts +13 -0
@@ -0,0 +1,781 @@
1
+ /**
2
+ * worker-source.ts — the JavaScript that runs inside the workflow worker thread.
3
+ *
4
+ * The host spawns this with `new Worker(WORKER_SOURCE, { eval: true })`, so the
5
+ * source has to be an inlined string: `AGENTS.md` forbids dynamic `import()`,
6
+ * and a file path would have to survive bundling. Keeping it as a template
7
+ * literal costs editor tooling but nothing else — the worker is plain CommonJS
8
+ * JavaScript and never sees the TypeScript pipeline.
9
+ *
10
+ * Two boundaries stack here, and they are not the same boundary:
11
+ *
12
+ * host thread ←postMessage→ worker thread ←vm context→ workflow script
13
+ *
14
+ * The worker/host split exists for *killability*: `worker.terminate()` stops a
15
+ * runaway script mid-loop, which an in-process `vm` timeout cannot do once the
16
+ * script is inside an `await`. The vm context exists for *determinism and
17
+ * accident-avoidance*, not security — see the note on `codeGeneration` below.
18
+ *
19
+ * ## Why the context gets no host built-ins
20
+ *
21
+ * `vm.createContext(sandbox)` gives the script a fresh realm that already owns
22
+ * `Object`, `Array`, `JSON`, `Math`, `Date`, `Promise`, `Map`, `Set`. We inject
23
+ * *only* our own globals on top. Injecting host built-ins instead would hand the
24
+ * script `Object.constructor` → the **host** `Function`, i.e. a compiler for
25
+ * arbitrary host-realm code.
26
+ *
27
+ * That said: our injected globals are themselves host closures, so
28
+ * `agent.constructor` is still the host `Function`. The hygiene shrinks the
29
+ * surface; it does not close the hole. **`codeGeneration: { strings: false }` is
30
+ * the load-bearing defense** — it makes `Function("…")` and `eval("…")` throw
31
+ * `EvalError`, so a captured host `Function` cannot compile anything. Treat this
32
+ * as a determinism boundary, not a security boundary against a hostile script.
33
+ *
34
+ * ## Why determinism is a prelude and not a stub
35
+ *
36
+ * Because `Date` and `Math` come *from the realm*, they cannot be neutered by
37
+ * injection — there is nothing to inject over. So the compiled source is
38
+ * prefixed with a prelude that runs inside the realm and reassigns `Date.now`
39
+ * and `Math.random` in place, then lexically shadows `Date` with a subclass
40
+ * whose zero-argument constructor throws. Lexical shadowing rather than a global
41
+ * assignment because a `const` in the IIFE scope cannot be reached around.
42
+ *
43
+ * Determinism is enforced because a workflow's journal is replayed by prefix on
44
+ * resume: a script that reads the clock produces a different prefix on the
45
+ * second run and the replay silently diverges.
46
+ */
47
+
48
+ /**
49
+ * Runs inside the realm, ahead of the script body, on a single line.
50
+ *
51
+ * One line matters: the body is compiled at `\n` + line 1, and the host passes
52
+ * `lineOffset: -1` so reported line numbers match the file the author wrote. Any
53
+ * newline in here shifts every stack frame in every workflow script.
54
+ */
55
+ const DETERMINISM_PRELUDE =
56
+ "const Date = (function () {" +
57
+ " const RealDate = globalThis.Date;" +
58
+ " const die = function (what) {" +
59
+ ' throw new Error(what + " is unavailable in workflow scripts (breaks resume).' +
60
+ ' Stamp results after the workflow returns, or pass timestamps via `args`.");' +
61
+ " };" +
62
+ ' RealDate.now = function () { return die("Date.now()"); };' +
63
+ ' Math.random = function () { return die("Math.random()"); };' +
64
+ " return class WorkflowDate extends RealDate {" +
65
+ ' constructor() { if (arguments.length === 0) die("new Date()"); super(...arguments); }' +
66
+ " };" +
67
+ "})();"
68
+
69
+ export const WORKER_SOURCE = `"use strict";
70
+
71
+ const { parentPort, workerData } = require("node:worker_threads");
72
+ const vm = require("node:vm");
73
+
74
+ const port = parentPort;
75
+ const ITEM_CAP = workerData.itemCap;
76
+ const PRELUDE = ${JSON.stringify(DETERMINISM_PRELUDE)};
77
+
78
+ /* ------------------------------------------------------------------ *
79
+ * RPC to the host
80
+ *
81
+ * The script never touches the agent manager. Every effect leaves as a
82
+ * "call" message and comes back as a "response", so the host owns the
83
+ * semaphore, the caps, and the abort story.
84
+ * ------------------------------------------------------------------ */
85
+
86
+ let nextCallId = 1;
87
+ const pendingCalls = new Map();
88
+
89
+ /**
90
+ * Output tokens this run has spent, as last reported by the host.
91
+ *
92
+ * A mirror, not a tally: every response carries the host's current total, so
93
+ * there is exactly one counter and it cannot drift. Between responses it cannot
94
+ * be stale in any way the script could observe — tokens only accrue through
95
+ * agents, and an agent's response is the only thing the script waits on.
96
+ */
97
+ let spentOutput = 0;
98
+
99
+ function callHost(method, payload) {
100
+ // Drain first, so the phase() that named this agent reaches the host ahead of
101
+ // the agent entry rather than a tick behind it.
102
+ flushProgress();
103
+ return new Promise(function (resolve, reject) {
104
+ const callId = nextCallId++;
105
+ pendingCalls.set(callId, { resolve: resolve, reject: reject });
106
+ port.postMessage({ type: "call", callId: callId, method: method, payload: payload });
107
+ });
108
+ }
109
+
110
+ port.on("message", function (message) {
111
+ if (!message || message.type !== "response") return;
112
+ if (typeof message.spent === "number") spentOutput = message.spent;
113
+ const waiter = pendingCalls.get(message.callId);
114
+ if (!waiter) return;
115
+ pendingCalls.delete(message.callId);
116
+ if (message.ok) {
117
+ waiter.resolve(message.value);
118
+ return;
119
+ }
120
+ const error = new Error(message.error || "The workflow host rejected the call.");
121
+ // Fatal errors are the run's, not the item's: parallel() and pipeline()
122
+ // swallow ordinary failures into null, and a cap breach must not be
123
+ // silently absorbed that way.
124
+ if (message.fatal) error.workflowFatal = true;
125
+ waiter.reject(error);
126
+ });
127
+
128
+ function isFatal(error) {
129
+ return !!(error && typeof error === "object" && error.workflowFatal === true);
130
+ }
131
+
132
+ /* ------------------------------------------------------------------ *
133
+ * Progress entries
134
+ * ------------------------------------------------------------------ */
135
+
136
+ let progressQueue = [];
137
+ let flushTimer = null;
138
+
139
+ function emit(entry) {
140
+ progressQueue.push(entry);
141
+ // Batched on a macrotask: a fan-out emits a burst of phase/log entries in one
142
+ // turn, and the host renders once per batch rather than once per entry.
143
+ if (flushTimer === null) flushTimer = setTimeout(flushProgress, 0);
144
+ }
145
+
146
+ function flushProgress() {
147
+ if (flushTimer !== null) {
148
+ clearTimeout(flushTimer);
149
+ flushTimer = null;
150
+ }
151
+ if (progressQueue.length === 0) return;
152
+ const batch = progressQueue;
153
+ progressQueue = [];
154
+ port.postMessage({ type: "progress", entries: batch });
155
+ }
156
+
157
+ /* ------------------------------------------------------------------ *
158
+ * The JSON boundary
159
+ *
160
+ * Checked here rather than relying on structured clone, which happily
161
+ * carries cycles, BigInt and Maps that the progress log and the resume
162
+ * journal cannot represent. Rejecting loudly beats writing a journal
163
+ * that will not replay.
164
+ * ------------------------------------------------------------------ */
165
+
166
+ let realmObjectPrototype = null;
167
+ /**
168
+ * The realm's own \`JSON.parse\`.
169
+ *
170
+ * Module-scope, not local to main(), because \`agent({ schema })\` parses its
171
+ * result here — outside main's closure — and the object has to carry the
172
+ * *script's* Object.prototype, not the worker's, or \`instanceof Object\` fails
173
+ * inside the script it was handed to.
174
+ */
175
+ let realmParse = null;
176
+
177
+ /**
178
+ * The top-level script's scope.
179
+ *
180
+ * Module-scope because a nested \`workflow()\` needs the realm-native function
181
+ * compiler that \`main()\` builds, and because the compiled child function is
182
+ * cached per body — see {@link workflowIn}.
183
+ */
184
+ let rootScope = null;
185
+ /**
186
+ * The vm context every script runs in.
187
+ *
188
+ * Held so a nested \`workflow()\` can compile its child there. Compiled from
189
+ * *outside* the realm, with \`vm.Script\`, because the context itself has
190
+ * \`codeGeneration.strings\` off — the script cannot build code, but the worker
191
+ * that owns it still can.
192
+ */
193
+ let realmContext = null;
194
+ /** Nested invocations made so far, against \`workerData.nestedCap\`. */
195
+ let nestedCount = 0;
196
+
197
+ function boundaryError(what, path) {
198
+ return new Error(
199
+ "Cannot pass " + what + " across the workflow VM boundary (at " + path + ")."
200
+ );
201
+ }
202
+
203
+ function assertBoundary(value, path, seen) {
204
+ if (value === null) return;
205
+ const kind = typeof value;
206
+ if (kind === "string" || kind === "boolean") return;
207
+ if (kind === "number") {
208
+ if (!Number.isFinite(value)) throw boundaryError("a non-finite number", path);
209
+ return;
210
+ }
211
+ if (kind === "undefined") {
212
+ if (path === "the workflow result") return;
213
+ throw boundaryError("undefined", path);
214
+ }
215
+ if (kind === "bigint") throw boundaryError("a BigInt", path);
216
+ if (kind === "symbol") throw boundaryError("a symbol", path);
217
+ if (kind === "function") throw boundaryError("a function", path);
218
+ if (kind !== "object") throw boundaryError("a " + kind, path);
219
+
220
+ if (seen.has(value)) throw boundaryError("a circular structure", path);
221
+ seen.add(value);
222
+
223
+ if (Object.getOwnPropertySymbols(value).length > 0) {
224
+ throw boundaryError("an object with symbol keys", path);
225
+ }
226
+
227
+ if (Array.isArray(value)) {
228
+ const length = value.length;
229
+ for (let i = 0; i < length; i++) {
230
+ // A sparse array round-trips through JSON as nulls, which silently
231
+ // changes the data. Reject instead.
232
+ if (!Object.prototype.hasOwnProperty.call(value, i)) {
233
+ throw boundaryError("a sparse array", path + "[" + i + "]");
234
+ }
235
+ assertBoundary(value[i], path + "[" + i + "]", seen);
236
+ }
237
+ seen.delete(value);
238
+ return;
239
+ }
240
+
241
+ const prototype = Object.getPrototypeOf(value);
242
+ // Two prototypes are legitimate: the realm's own Object.prototype (anything
243
+ // the script built) and the worker's (arrays we hand back from parallel).
244
+ // Everything else — Map, Set, Date, a class instance — loses meaning here.
245
+ if (prototype !== null && prototype !== realmObjectPrototype && prototype !== Object.prototype) {
246
+ throw boundaryError("a non-plain object", path);
247
+ }
248
+
249
+ const keys = Object.keys(value);
250
+ for (let i = 0; i < keys.length; i++) {
251
+ assertBoundary(value[keys[i]], path + "." + keys[i], seen);
252
+ }
253
+ seen.delete(value);
254
+ }
255
+
256
+ function checkBoundary(value, path) {
257
+ assertBoundary(value, path, new Set());
258
+ return value;
259
+ }
260
+
261
+ /* ------------------------------------------------------------------ *
262
+ * Realm helpers
263
+ *
264
+ * parallel() and pipeline() build their result arrays in worker code, but
265
+ * the script should get an array its own realm recognises — otherwise
266
+ * \`result instanceof Array\` is false and \`Array.isArray\` is the only thing
267
+ * that works. Item values are moved across untouched.
268
+ * ------------------------------------------------------------------ */
269
+
270
+ let realmNewArray = null;
271
+ let realmPush = null;
272
+
273
+ function toRealmArray(items) {
274
+ const array = realmNewArray();
275
+ for (let i = 0; i < items.length; i++) realmPush(array, items[i]);
276
+ return array;
277
+ }
278
+
279
+ function toList(value, what) {
280
+ if (!Array.isArray(value)) throw new Error(what + " expects an array.");
281
+ const length = value.length >>> 0;
282
+ if (length > ITEM_CAP) {
283
+ throw new Error(
284
+ what + " was given " + length + " items, over the limit of " + ITEM_CAP + "."
285
+ );
286
+ }
287
+ const out = [];
288
+ for (let i = 0; i < length; i++) out.push(value[i]);
289
+ return out;
290
+ }
291
+
292
+ function requireText(value, what) {
293
+ if (typeof value !== "string" || value.trim() === "") {
294
+ throw new Error(what + " requires a non-empty string.");
295
+ }
296
+ return value;
297
+ }
298
+
299
+ function optionalText(value, what) {
300
+ if (value === undefined || value === null) return undefined;
301
+ return requireText(value, what);
302
+ }
303
+
304
+ /**
305
+ * Reasoning effort a child may be spawned under — pi's \`ThinkingLevel\`.
306
+ *
307
+ * A superset of Claude Code's five, so a script written there runs here; the
308
+ * extra \`minimal\` is pi's own. Validated in the worker rather than the host
309
+ * because a typo should stop the script at the call that made it, not surface
310
+ * later as an agent that quietly ran at the wrong depth.
311
+ */
312
+ const EFFORT_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
313
+
314
+ /**
315
+ * Every option \`agent()\` understands.
316
+ *
317
+ * Checked rather than ignored, because the alternative is the worst failure a
318
+ * ported script can have. Claude Code's \`agent()\` also takes \`schema\`, and its
319
+ * own canonical example uses it; quietly dropping it hands the script the
320
+ * agent's raw text where it expected a validated object, and the run then dies
321
+ * several lines later reading a field off a string. A typo behaves the same
322
+ * way. Naming the option costs one error message and no model calls.
323
+ */
324
+ const AGENT_OPTIONS = [
325
+ "label",
326
+ "phase",
327
+ "model",
328
+ "agentType",
329
+ "isolation",
330
+ "gate",
331
+ "resume",
332
+ "effort",
333
+ "schema",
334
+ ];
335
+
336
+ /** Claude Code options this runtime does not have, and why. */
337
+ const UNSUPPORTED_AGENT_OPTIONS = {};
338
+
339
+ /* ------------------------------------------------------------------ *
340
+ * Script globals
341
+ * ------------------------------------------------------------------ */
342
+
343
+ /**
344
+ * Phase indices are allocated once for the whole run, so parent and child never
345
+ * collide. What is per-scope is the *title to index* map: a child's
346
+ * \`phase("Scan")\` must not resolve to the parent's "Scan".
347
+ */
348
+ let nextPhaseIndex = 0;
349
+
350
+ /**
351
+ * One script's view of the world.
352
+ *
353
+ * A nested \`workflow()\` runs in this same worker and this same vm context —
354
+ * which is what makes it share the run's semaphore, agent counter, journal,
355
+ * abort signal and budget without any of them being plumbed anywhere. What it
356
+ * must NOT share is ambient phase state, so that lives here and the child's
357
+ * globals are closures over its own scope.
358
+ */
359
+ function makeScope(name, depth) {
360
+ const scope = {
361
+ name: name,
362
+ depth: depth,
363
+ // Prefixed into every phase title the child defines, which is the whole of
364
+ // how a nested run reads as its own group in the progress tree — no new
365
+ // entry type, no renderer change.
366
+ prefix: name === undefined ? "" : "\u25b8 " + name,
367
+ ambientPhaseIndex: undefined,
368
+ ambientPhaseTitle: undefined,
369
+ phaseIndexByTitle: new Map(),
370
+ };
371
+ scope.agent = function (prompt, opts) {
372
+ return agentIn(scope, prompt, opts);
373
+ };
374
+ scope.phase = function (title) {
375
+ return phaseIn(scope, title);
376
+ };
377
+ scope.log = function (message) {
378
+ return logIn(scope, message);
379
+ };
380
+ scope.workflow = function (ref, args) {
381
+ return workflowIn(scope, ref, args);
382
+ };
383
+ scope.console = makeConsole(scope);
384
+ return scope;
385
+ }
386
+
387
+ /** A scope's title for a phase: the child's own group, or the parent's bare title. */
388
+ function scopedTitle(scope, title) {
389
+ if (scope.prefix === "") return title;
390
+ return title === undefined ? scope.prefix : scope.prefix + " \u203a " + title;
391
+ }
392
+
393
+ function definePhaseIn(scope, title) {
394
+ let index = scope.phaseIndexByTitle.get(title);
395
+ if (index !== undefined) return index;
396
+ index = nextPhaseIndex++;
397
+ scope.phaseIndexByTitle.set(title, index);
398
+ emit({ type: "workflow_phase", index: index, title: scopedTitle(scope, title) });
399
+ return index;
400
+ }
401
+
402
+ function phaseIn(scope, title) {
403
+ const text = requireText(title, "phase(title)");
404
+ scope.ambientPhaseIndex = definePhaseIn(scope, text);
405
+ scope.ambientPhaseTitle = scopedTitle(scope, text);
406
+ }
407
+
408
+ function describe(value) {
409
+ if (typeof value === "string") return value;
410
+ // Duck-typed, not \`instanceof Error\`: an error thrown by the script belongs
411
+ // to the vm realm, so it fails an instanceof check against the worker's.
412
+ if (value && typeof value === "object" && typeof value.message === "string" && typeof value.stack === "string") {
413
+ return value.message;
414
+ }
415
+ try {
416
+ const json = JSON.stringify(value);
417
+ if (json !== undefined) return json;
418
+ } catch {
419
+ /* cycles and BigInt fall through to String() */
420
+ }
421
+ return String(value);
422
+ }
423
+
424
+ /** Attribute a line to the child that wrote it; logs carry no phase of their own. */
425
+ function logPrefix(scope) {
426
+ return scope.prefix === "" ? "" : scope.prefix + ": ";
427
+ }
428
+
429
+ function logIn(scope, message) {
430
+ emit({ type: "workflow_log", message: logPrefix(scope) + describe(message) });
431
+ }
432
+
433
+ function makeConsole(scope) {
434
+ const write = function () {
435
+ const parts = [];
436
+ for (let i = 0; i < arguments.length; i++) parts.push(describe(arguments[i]));
437
+ emit({ type: "workflow_log", message: logPrefix(scope) + parts.join(" ") });
438
+ };
439
+ return { log: write, info: write, warn: write, error: write, debug: write };
440
+ }
441
+
442
+ async function agentIn(scope, prompt, opts) {
443
+ const text = requireText(prompt, "agent(prompt)");
444
+ const options = opts === undefined || opts === null ? {} : opts;
445
+ if (typeof options !== "object" || Array.isArray(options)) {
446
+ throw new Error("agent(prompt, opts) expects opts to be an object.");
447
+ }
448
+
449
+ for (const key of Object.keys(options)) {
450
+ if (AGENT_OPTIONS.indexOf(key) !== -1) continue;
451
+ const why = UNSUPPORTED_AGENT_OPTIONS[key];
452
+ throw new Error(
453
+ why !== undefined
454
+ ? "agent() opts." + key + " is not supported here: " + why
455
+ : "agent() opts." + key + " is not a recognised option. Supported: " + AGENT_OPTIONS.join(", ") + "."
456
+ );
457
+ }
458
+
459
+ const label = optionalText(options.label, "agent() opts.label");
460
+ const phaseName = optionalText(options.phase, "agent() opts.phase");
461
+ const model = optionalText(options.model, "agent() opts.model");
462
+ const agentType = optionalText(options.agentType, "agent() opts.agentType");
463
+ const isolation = optionalText(options.isolation, "agent() opts.isolation");
464
+ if (isolation !== undefined && isolation !== "worktree") {
465
+ throw new Error("agent() opts.isolation must be \\"worktree\\".");
466
+ }
467
+ const gate = optionalText(options.gate, "agent() opts.gate");
468
+ const resume = optionalText(options.resume, "agent() opts.resume");
469
+ const effort = optionalText(options.effort, "agent() opts.effort");
470
+ const schema = options.schema;
471
+ if (schema !== undefined) {
472
+ if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
473
+ throw new Error("agent() opts.schema must be a JSON Schema object.");
474
+ }
475
+ // Structured clone would happily carry a Map or a cycle that neither the
476
+ // journal key nor the tool's parameters can survive. Same check the return
477
+ // value gets.
478
+ checkBoundary(schema, "agent() opts.schema");
479
+ }
480
+ if (effort !== undefined && EFFORT_LEVELS.indexOf(effort) === -1) {
481
+ throw new Error("agent() opts.effort must be one of: " + EFFORT_LEVELS.join(", ") + ".");
482
+ }
483
+
484
+ // resume revives a child that already exists, so anything describing how to
485
+ // *start* one is not a thing this call gets to decide — the revived child
486
+ // keeps the agent, model and tool contract it was started with. Rejecting is
487
+ // the point: silently ignoring these opts would look like they applied.
488
+ if (resume !== undefined) {
489
+ if (agentType !== undefined) {
490
+ throw new Error(
491
+ "agent() opts.resume and opts.agentType are mutually exclusive: a resumed agent keeps the agent type it was started with."
492
+ );
493
+ }
494
+ if (model !== undefined) {
495
+ throw new Error(
496
+ "agent() opts.resume and opts.model are mutually exclusive: a resumed agent keeps the model it was started with."
497
+ );
498
+ }
499
+ if (isolation !== undefined) {
500
+ throw new Error(
501
+ "agent() opts.resume and opts.isolation are mutually exclusive: a resumed agent keeps the working tree it was started in."
502
+ );
503
+ }
504
+ if (effort !== undefined) {
505
+ throw new Error(
506
+ "agent() opts.resume and opts.effort are mutually exclusive: a resumed agent keeps the reasoning effort it was started with."
507
+ );
508
+ }
509
+ if (schema !== undefined) {
510
+ throw new Error(
511
+ "agent() opts.resume and opts.schema are mutually exclusive: a resumed child re-prompts the session it "
512
+ + "already had, whose tool set was fixed when it started — it has no StructuredOutput tool to answer through."
513
+ );
514
+ }
515
+ if (gate !== undefined) {
516
+ throw new Error("agent() opts.gate cannot be combined with opts.resume.");
517
+ }
518
+ }
519
+
520
+ // An explicit opts.phase files this agent under that phase without moving
521
+ // the ambient one, so a stray verify step does not re-point the phases that
522
+ // follow it.
523
+ const phaseIndex = phaseName !== undefined ? definePhaseIn(scope, phaseName) : scope.ambientPhaseIndex;
524
+ const phaseTitle = phaseName !== undefined ? scopedTitle(scope, phaseName) : scope.ambientPhaseTitle;
525
+
526
+ const result = await callHost("agent", {
527
+ prompt: text,
528
+ label: label,
529
+ model: model,
530
+ agentType: agentType,
531
+ isolation: isolation,
532
+ phaseIndex: phaseIndex,
533
+ phaseTitle: phaseTitle,
534
+ gate: gate,
535
+ resume: resume,
536
+ effort: effort,
537
+ schema: schema,
538
+ });
539
+ if (result === undefined || result === null) return null;
540
+ if (schema === undefined) return result;
541
+ // Parsed with the realm's own JSON.parse so the script gets an object whose
542
+ // prototype is its own — \`x instanceof Object\` and \`x.list instanceof Array\`
543
+ // both hold, and it survives assertBoundary if the script returns it.
544
+ try {
545
+ return realmParse(result);
546
+ } catch (error) {
547
+ logIn(scope, "agent(): the host returned a structured result that is not JSON");
548
+ return null;
549
+ }
550
+ }
551
+
552
+ /**
553
+ * A barrier: every thunk starts now, and nothing past the await runs until all
554
+ * of them have settled. A thunk that throws resolves to null rather than
555
+ * failing its siblings — the script filters, it does not try/catch.
556
+ */
557
+ async function parallel(thunks) {
558
+ const list = toList(thunks, "parallel(thunks)");
559
+ for (let i = 0; i < list.length; i++) {
560
+ if (typeof list[i] !== "function") {
561
+ throw new Error("parallel(thunks) expects an array of functions; item " + i + " is not one.");
562
+ }
563
+ }
564
+ const settled = await Promise.all(
565
+ list.map(async function (thunk) {
566
+ try {
567
+ return await thunk();
568
+ } catch (error) {
569
+ if (isFatal(error)) throw error;
570
+ return null;
571
+ }
572
+ })
573
+ );
574
+ return toRealmArray(settled);
575
+ }
576
+
577
+ /**
578
+ * No barrier between stages. Each item walks its own chain, so item A can be
579
+ * in stage 3 while item B is still in stage 1 — which is the whole point:
580
+ * a barrier makes every stage wait on its slowest sibling, and with agents in
581
+ * the stages that latency is measured in minutes.
582
+ *
583
+ * A stage that throws drops that item to null and skips its remaining stages.
584
+ * Every stage sees (previousResult, originalItem, index).
585
+ */
586
+ async function pipeline(items, ...stages) {
587
+ const list = toList(items, "pipeline(items, ...stages)");
588
+ for (let i = 0; i < stages.length; i++) {
589
+ if (typeof stages[i] !== "function") {
590
+ throw new Error("pipeline(items, ...stages) expects stages to be functions; stage " + i + " is not one.");
591
+ }
592
+ }
593
+ const settled = await Promise.all(
594
+ list.map(async function (item, index) {
595
+ let value = item;
596
+ for (let s = 0; s < stages.length; s++) {
597
+ try {
598
+ value = await stages[s](value, item, index);
599
+ } catch (error) {
600
+ if (isFatal(error)) throw error;
601
+ return null;
602
+ }
603
+ }
604
+ return value;
605
+ })
606
+ );
607
+ return toRealmArray(settled);
608
+ }
609
+
610
+ /**
611
+ * The \`workflow(nameOrRef, args?)\` global.
612
+ *
613
+ * Runs another workflow inline. The child executes in *this* worker and *this*
614
+ * vm context, as a function whose parameters shadow the globals — which is why
615
+ * it shares the run's concurrency cap, agent counter, abort signal, journal and
616
+ * budget without any of them being passed anywhere: there is only ever one of
617
+ * each. What it does not share is ambient phase state, which lives on the scope.
618
+ *
619
+ * One level only, as in Claude Code. The child's \`workflow\` is present and
620
+ * throws rather than absent, so the error names the limit instead of reading
621
+ * \`workflow is not defined\`.
622
+ */
623
+ async function workflowIn(scope, nameOrRef, args) {
624
+ if (scope.depth > 0) {
625
+ throw new Error(
626
+ "workflow() cannot be nested more than one level deep — you are already inside the workflow '" +
627
+ scope.name + "'. Call the agents inline instead."
628
+ );
629
+ }
630
+
631
+ let ref;
632
+ if (typeof nameOrRef === "string") {
633
+ if (nameOrRef.trim() === "") throw new Error("workflow(nameOrRef) expects a non-empty name.");
634
+ ref = { name: nameOrRef };
635
+ } else if (nameOrRef && typeof nameOrRef === "object" && !Array.isArray(nameOrRef)) {
636
+ const scriptPath = optionalText(nameOrRef.scriptPath, "workflow() scriptPath");
637
+ const name = optionalText(nameOrRef.name, "workflow() name");
638
+ if (scriptPath === undefined && name === undefined) {
639
+ throw new Error("workflow({ ... }) expects a \`name\` or a \`scriptPath\`.");
640
+ }
641
+ ref = { name: name, scriptPath: scriptPath };
642
+ } else {
643
+ throw new Error("workflow(nameOrRef) expects a saved workflow name or { scriptPath }.");
644
+ }
645
+
646
+ const label = ref.name !== undefined ? ref.name : ref.scriptPath;
647
+ if (args !== undefined) checkBoundary(args, 'workflow("' + label + '") args');
648
+
649
+ if (nestedCount >= workerData.nestedCap) {
650
+ // Fatal, like the agent cap: a limit that silently drops work would be
651
+ // worse than no limit.
652
+ const error = new Error(
653
+ "Workflow exceeded its cap of " + workerData.nestedCap + " nested workflow() calls."
654
+ );
655
+ error.workflowFatal = true;
656
+ throw error;
657
+ }
658
+ nestedCount++;
659
+
660
+ let loaded;
661
+ try {
662
+ loaded = await callHost("workflow", ref);
663
+ } catch (error) {
664
+ // Resolution failures are the script's to handle — Claude Code documents
665
+ // workflow() as throwing on an unknown name so a script can catch it.
666
+ // Attributed, so a caught error says which reference failed.
667
+ if (isFatal(error)) throw error;
668
+ throw new Error('workflow("' + label + '"): ' + describe(error));
669
+ }
670
+
671
+ const child = makeScope(loaded.name, scope.depth + 1);
672
+ // The child's own group, defined before its first agent so a child that never
673
+ // calls phase() still reads as its own section rather than falling into the
674
+ // parent's un-phased bucket.
675
+ child.ambientPhaseIndex = definePhaseIn(child, undefined);
676
+ child.ambientPhaseTitle = scopedTitle(child, undefined);
677
+
678
+ let run;
679
+ try {
680
+ const compiled = new vm.Script(
681
+ // \`meta\` is deliberately not a parameter: the body still opens with its
682
+ // own \`const meta = { ... }\` (extractMeta strips only the \`export\`), so a
683
+ // parameter of that name would collide with it.
684
+ "(async (agent, phase, log, workflow, console, args) => {" + PRELUDE + "\\n" + loaded.body + "\\n})",
685
+ { filename: "workflow:" + loaded.name + ".js", lineOffset: -1 }
686
+ );
687
+ run = compiled.runInContext(realmContext);
688
+ } catch (error) {
689
+ throw new Error('workflow("' + label + '"): ' + describe(error));
690
+ }
691
+
692
+ const value = await run(child.agent, child.phase, child.log, child.workflow, child.console, args);
693
+ checkBoundary(value, 'the result of workflow("' + label + '")');
694
+ return value;
695
+ }
696
+
697
+ /**
698
+ * The \`budget\` global.
699
+ *
700
+ * \`total\` is permanently null, and that is the honest answer rather than a
701
+ * stub: Claude Code fills it from the user's "+500k" directive and pi has no
702
+ * such directive, so "no target set" is the state this runtime is always in.
703
+ * Every pattern Claude Code documents guards on exactly that — \`while
704
+ * (budget.total && ...)\`, \`budget.total ? ... : 5\` — so those scripts run here
705
+ * unchanged and take the branch they were written for. Leaving \`budget\`
706
+ * undefined instead would turn a graceful guard into a ReferenceError.
707
+ *
708
+ * \`spent()\` is real. It differs from Claude Code's in scope: theirs pools the
709
+ * main loop and every workflow in the turn, ours counts this run's agents.
710
+ */
711
+ function makeBudget() {
712
+ return {
713
+ total: null,
714
+ spent: function () {
715
+ return spentOutput;
716
+ },
717
+ remaining: function () {
718
+ // Infinity, not a number, because there is no target to subtract from.
719
+ return Infinity;
720
+ },
721
+ };
722
+ }
723
+
724
+ /* ------------------------------------------------------------------ *
725
+ * Run
726
+ * ------------------------------------------------------------------ */
727
+
728
+ async function main() {
729
+ rootScope = makeScope(undefined, 0);
730
+ const sandbox = {
731
+ agent: rootScope.agent,
732
+ parallel: parallel,
733
+ pipeline: pipeline,
734
+ phase: rootScope.phase,
735
+ log: rootScope.log,
736
+ workflow: rootScope.workflow,
737
+ budget: makeBudget(),
738
+ console: rootScope.console,
739
+ };
740
+ const context = vm.createContext(sandbox, {
741
+ name: "workflow",
742
+ codeGeneration: { strings: false, wasm: false },
743
+ });
744
+
745
+ realmObjectPrototype = vm.runInContext("Object.prototype", context);
746
+ realmNewArray = vm.runInContext("(function () { return []; })", context);
747
+ realmPush = vm.runInContext("(function (array, value) { array.push(value); })", context);
748
+ realmParse = vm.runInContext("JSON.parse", context);
749
+ realmContext = context;
750
+
751
+ // meta and args are materialised *inside* the realm rather than injected, so
752
+ // the script sees objects whose prototype is its own Object.prototype and
753
+ // whose .constructor is its own Function.
754
+ sandbox.meta = realmParse(workerData.metaJson);
755
+ sandbox.args = workerData.argsJson === undefined ? undefined : realmParse(workerData.argsJson);
756
+
757
+ const script = new vm.Script("(async () => {" + PRELUDE + "\\n" + workerData.body + "\\n})()", {
758
+ filename: "workflow.js",
759
+ // The wrapper adds exactly one line above the body; undo it so a thrown
760
+ // error points at the line the author wrote.
761
+ lineOffset: -1,
762
+ });
763
+
764
+ const value = await script.runInContext(context);
765
+ checkBoundary(value, "the workflow result");
766
+ flushProgress();
767
+ port.postMessage({
768
+ type: "complete",
769
+ resultJson: value === undefined ? undefined : JSON.stringify(value),
770
+ });
771
+ }
772
+
773
+ main().catch(function (error) {
774
+ flushProgress();
775
+ port.postMessage({
776
+ type: "error",
777
+ message: error && error.message ? String(error.message) : String(error),
778
+ stack: error && error.stack ? String(error.stack) : undefined,
779
+ });
780
+ });
781
+ `