@nredd/pi-subagents 0.22.0-nredd.8 → 0.22.0-nredd.9

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/README.md +136 -55
  2. package/package.json +14 -11
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +0 -1
  5. package/src/agent-manager.ts +360 -103
  6. package/src/agent-runner.ts +121 -63
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +10 -6
  10. package/src/custom-agents.ts +2 -1
  11. package/src/default-agents.ts +19 -17
  12. package/src/detached-results.ts +18 -2
  13. package/src/enterprise-local.ts +2 -1
  14. package/src/error-message.ts +11 -0
  15. package/src/git-path.ts +66 -0
  16. package/src/group-join.ts +4 -10
  17. package/src/index.ts +731 -231
  18. package/src/invocation-config.ts +1 -5
  19. package/src/mention-clone.ts +4 -3
  20. package/src/model-resolver.ts +51 -14
  21. package/src/model-selection.ts +17 -6
  22. package/src/model-tiers.ts +5 -0
  23. package/src/nested-tools.ts +80 -21
  24. package/src/notification-result.ts +118 -0
  25. package/src/plan-mode-children.ts +176 -0
  26. package/src/prompts.ts +7 -2
  27. package/src/quota-admission.ts +16 -6
  28. package/src/resource-loader.ts +20 -6
  29. package/src/schedule.ts +65 -19
  30. package/src/settings.ts +21 -1
  31. package/src/skill-loader.ts +244 -94
  32. package/src/status-note.ts +28 -5
  33. package/src/types.ts +52 -2
  34. package/src/ui/agent-mention.ts +5 -2
  35. package/src/ui/agent-widget.ts +41 -22
  36. package/src/ui/conversation-viewer.ts +4 -2
  37. package/src/ui/fallback-flag.ts +28 -4
  38. package/src/ui/fleet-list.ts +89 -54
  39. package/src/ui/notification-view.ts +4 -1
  40. package/src/ui/workflow-card.ts +1 -8
  41. package/src/ui/workflow-dialog.ts +31 -18
  42. package/src/workflow/host.ts +4 -5
  43. package/src/workflow/meta.ts +7 -4
  44. package/src/workflow/progress.ts +4 -3
  45. package/src/workflow/runtime.ts +23 -7
  46. package/src/workflow/saved.ts +2 -4
  47. package/src/workflow/task.ts +12 -7
  48. package/src/workflow/tool-description.ts +2 -2
  49. package/src/workflow/worker-source.ts +6 -2
  50. package/src/worktree.ts +309 -19
@@ -61,12 +61,11 @@ import {
61
61
  type WorkflowDisplayState,
62
62
  type WorkflowEntry,
63
63
  } from "../workflow/progress.js";
64
- import { SPINNER, type Theme } from "./agent-widget.js";
65
- import { fallbackFlagText, hasFallback } from "./fallback-flag.js";
64
+ import { formatCompactTokens, SPINNER, type Theme } from "./agent-widget.js";
65
+ import { boundedWrappedRows, fallbackFlagText, hasFallback, withLeadingFlag } from "./fallback-flag.js";
66
66
  import {
67
67
  ASCII_GLYPHS,
68
68
  clampLine,
69
- formatCompactTokens,
70
69
  formatModel,
71
70
  formatThinking,
72
71
  REPLAYED_ANNOTATION,
@@ -105,6 +104,8 @@ export const MIN_PANE_BODY_ROWS = 6;
105
104
  export const PROMPT_COLLAPSED_LINES = 4;
106
105
  /** Spinner cadence. Unlike the card's 1s header tick, this row really animates. */
107
106
  export const WORKFLOW_DIALOG_SPINNER_MS = 80;
107
+ /** Maximum physical detail rows a fallback note may occupy. */
108
+ const MODEL_NOTE_ROWS = 4;
108
109
 
109
110
  /* ------------------------------------------------------------------------- *
110
111
  * Glyphs
@@ -459,12 +460,6 @@ export function leftPaneWidth(width: number): number {
459
460
  return Math.max(1, Math.min(LEFT_PANE_WIDTH, Math.floor(available / 3), available - 1));
460
461
  }
461
462
 
462
- /** Fill a title out to the cell width with rule, so the corners stay put. */
463
- function padTitle(line: WorkflowCardLine, width: number, horizontal: string): WorkflowCardLine {
464
- const gap = Math.max(0, width - lineWidth(line));
465
- return gap > 0 ? [...line, { text: horizontal.repeat(gap), color: "dim" }] : line;
466
- }
467
-
468
463
  /** One pane row, padded to exactly `width` so the frame stays vertical. */
469
464
  function padCell(line: WorkflowCardLine, width: number): WorkflowCardLine {
470
465
  const clamped = clampLine(line, width);
@@ -519,9 +514,9 @@ function paneFrame(options: {
519
514
  const lines: WorkflowCardLine[] = [];
520
515
  lines.push([
521
516
  { text: box.topLeft, color: "dim" },
522
- ...padTitle(frameTitle(options.leftTitle, left, glyphs), left, box.horizontal),
517
+ ...frameTitle(options.leftTitle, left, glyphs),
523
518
  { text: box.topTee, color: "dim" },
524
- ...padTitle(frameTitle(options.rightTitle, right, glyphs), right, box.horizontal),
519
+ ...frameTitle(options.rightTitle, right, glyphs),
525
520
  { text: box.topRight, color: "dim" },
526
521
  ]);
527
522
 
@@ -638,18 +633,33 @@ function agentRow(options: {
638
633
  }): WorkflowCardLine {
639
634
  const { entry, selected, glyphs, width } = options;
640
635
  const display = displayState(entry, options.workflowActive);
641
- const head: WorkflowCardLine = [
636
+ const prefix: WorkflowCardLine = [
642
637
  { text: " " },
643
638
  { text: selected ? glyphs.pointer : " ", color: "accent" },
644
639
  { text: " " },
645
640
  dialogRowGlyph(display, glyphs, options.spinnerFrame),
646
641
  { text: " " },
647
- // Leading, ahead of the label, in both pane modes: the row clamps from the right.
648
- ...(entry.modelTier || hasFallback(entry) ? [{ text: `${fallbackFlagText(1, entry)} `, color: hasFallback(entry) ? "warning" as const : "dim" as const }] : []),
642
+ ];
643
+ const flag = entry.modelTier || hasFallback(entry) ? fallbackFlagText(1, entry) : "";
644
+ const flagColor = hasFallback(entry) ? "warning" as const : "dim" as const;
645
+ // The narrow pane holds the label and nothing else; reserve its status prefix,
646
+ // then let the shared flag degrade rather than clipping it to `[fal…`.
647
+ if (options.compact && flag) {
648
+ const flagWidth = Math.max(0, width - lineWidth(prefix));
649
+ const compactFlag = withLeadingFlag("", flag, "", flagWidth);
650
+ const labelWidth = Math.max(0, flagWidth - visibleWidth(compactFlag) - 1);
651
+ const label = truncateToWidth(entry.label, labelWidth, "");
652
+ return [
653
+ ...prefix,
654
+ { text: compactFlag, color: flagColor },
655
+ ...(label ? [{ text: ` ${label}`, color: selected ? "accent" as const : undefined }] : []),
656
+ ];
657
+ }
658
+ const head: WorkflowCardLine = [
659
+ ...prefix,
660
+ ...(flag ? [{ text: `${flag} `, color: flagColor }] : []),
649
661
  { text: entry.label, color: selected ? "accent" : undefined },
650
662
  ];
651
- // The narrow pane holds the label and nothing else; there is no room for a
652
- // stat tail, and clamping one would just spend columns on a truncated word.
653
663
  if (options.compact) return clampLine(head, width);
654
664
 
655
665
  const model = formatModel(entry);
@@ -800,9 +810,12 @@ export function layoutWorkflowDialog(input: WorkflowDialogInput): WorkflowCardLi
800
810
  detailRows.push(clampLine([{ text: ` ${stats.join(" · ")}`, color: "dim" }], rightWidth));
801
811
  }
802
812
  // A substituted model is never cosmetic: name the failed input and what ran.
803
- // Wrapped, not clamped: this is the one place the full note is readable.
813
+ // Bound physical rows, not text width: a configured value can contain
814
+ // newlines, each of which otherwise displaces the final diagnosis below the
815
+ // pane's fixed row budget.
804
816
  if (entry.modelNote) {
805
- for (const line of wrapTextWithAnsi(entry.modelNote, Math.max(1, rightWidth - 1))) {
817
+ const noteWidth = Math.max(1, rightWidth - 1);
818
+ for (const line of boundedWrappedRows(entry.modelNote, noteWidth, MODEL_NOTE_ROWS, glyphs.ellipsis)) {
806
819
  detailRows.push(clampLine([{ text: ` ${line}`, color: "warning" }], rightWidth));
807
820
  }
808
821
  }
@@ -167,10 +167,6 @@ function toSpawnResult(record: AgentRecord, opts: { resumed?: boolean } = {}): W
167
167
  return { ...common, ...modelNote, ok: false, error: `${lead}${record.error ?? `Agent ${record.status}.`}` };
168
168
  }
169
169
 
170
- /** Shell used to run a `gate` command, mirroring how a user would type it. */
171
- const GATE_SHELL: readonly [string, string] =
172
- process.platform === "win32" ? ["cmd", "/c"] : ["sh", "-c"];
173
-
174
170
  export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
175
171
  const { pi, ctx, manager } = deps;
176
172
  /** Runtime agent id → the manager record it spawned. Never pruned mid-run. */
@@ -189,7 +185,10 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
189
185
  * decided from one behaviour, whichever route the command took.
190
186
  */
191
187
  async function executeGate(command: string, cwd: string): Promise<WorkflowGateResult> {
192
- const result = await pi.exec(GATE_SHELL[0], [GATE_SHELL[1], command], {
188
+ // The shell a user would type the command into. Read per call rather than
189
+ // at import, so the choice follows the platform the gate actually runs on.
190
+ const [shell, flag] = process.platform === "win32" ? ["cmd", "/c"] : ["sh", "-c"];
191
+ const result = await pi.exec(shell, [flag, command], {
193
192
  cwd,
194
193
  timeout: deps.gateTimeoutMs ?? DEFAULT_GATE_TIMEOUT_MS,
195
194
  ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
@@ -38,6 +38,8 @@ export interface WorkflowMeta {
38
38
 
39
39
  export interface MetaExtraction {
40
40
  meta: WorkflowMeta;
41
+ /** Byte offset immediately after the validated meta object literal. */
42
+ metaEnd: number;
41
43
  /**
42
44
  * The script with the leading `export ` stripped, so `const meta = {...}`
43
45
  * compiles inside the vm. Byte offsets after the keyword are untouched, which
@@ -175,8 +177,9 @@ function scanObjectLiteral(source: string, open: number): ScanResult {
175
177
  */
176
178
  function isRegexPosition(source: string, i: number): boolean {
177
179
  let j = i - 1;
178
- while (j >= 0 && /\s/.test(source[j])) j--;
179
- if (j < 0) return true;
180
+ // Never runs off the front: the scan starts at the literal's `{`, so the
181
+ // walk back always stops on it or something after it.
182
+ while (/\s/.test(source[j])) j--;
180
183
  const prev = source[j];
181
184
  // Identifier/number/closer before `/` means division.
182
185
  return !/[\w$)\]]/.test(prev);
@@ -289,7 +292,7 @@ export function extractMeta(source: string): MetaExtraction {
289
292
  const exportAt = source.indexOf("export", declaration.index);
290
293
  const body = `${source.slice(0, exportAt)}${" ".repeat(6)}${source.slice(exportAt + 6)}`;
291
294
 
292
- return { meta, body };
295
+ return { meta, metaEnd: close, body };
293
296
  }
294
297
 
295
298
  /**
@@ -309,7 +312,7 @@ export function workflowCallName(args: { script?: string; scriptPath?: string; n
309
312
  if (source === undefined || source === "") {
310
313
  // A path-only call would need a synchronous file read per rebuild to do
311
314
  // better than this, and the file name is what the author will recognize.
312
- if (args.scriptPath !== undefined) return args.scriptPath.split(/[/\\]/).pop() ?? "workflow";
315
+ if (args.scriptPath !== undefined) return args.scriptPath.replace(/^[\s\S]*[/\\]/, "");
313
316
  // A saved workflow is already named by the caller; no read needed at all.
314
317
  return args.name !== undefined && args.name !== "" ? args.name : "workflow";
315
318
  }
@@ -514,11 +514,12 @@ export function gerund(word: string): string {
514
514
 
515
515
  // Short consonant-vowel-consonant words double the final consonant: run →
516
516
  // running. `w`, `x` and `y` never double.
517
- const last = lower.at(-1) ?? "";
517
+ // GERUND_CANDIDATE guarantees at least three letters, so every index exists.
518
+ const last = lower[lower.length - 1];
518
519
  if (
519
520
  lower.length <= 4 &&
520
- !VOWELS.includes(lower.at(-3) ?? "") &&
521
- VOWELS.includes(lower.at(-2) ?? "") &&
521
+ !VOWELS.includes(lower[lower.length - 3]) &&
522
+ VOWELS.includes(lower[lower.length - 2]) &&
522
523
  !VOWELS.includes(last) &&
523
524
  !"wxy".includes(last)
524
525
  ) {
@@ -153,8 +153,8 @@ export interface WorkflowSpawnResult {
153
153
  * verifies the wrong working copy. Left unset, a gate runs wherever the host
154
154
  * runs commands by default.
155
155
  *
156
- * Usually unset for a worktree child even so: the copy is removed during the
157
- * child's own settle, so it no longer exists by the time this is read. That
156
+ * Usually unset for a worktree child even so: the copy is normally removed during
157
+ * the child's own settle, so it no longer exists by the time this is read. That
158
158
  * is what {@link gate} is for.
159
159
  */
160
160
  cwd?: string;
@@ -282,6 +282,19 @@ export interface RunWorkflowOptions {
282
282
  signal?: AbortSignal;
283
283
  /** Fired per batch, not per entry — see the worker's progress batching. */
284
284
  onProgress?(entries: readonly WorkflowEntry[]): void;
285
+ /**
286
+ * Receives optional Istanbul counters authored inside a workflow VM.
287
+ *
288
+ * The runtime itself never instruments scripts. Test producers may pass
289
+ * instrumented source and merge these counters into their own collector,
290
+ * retaining worker/VM execution rather than measuring a substitute runner.
291
+ */
292
+ onScriptCoverage?(coverage: unknown): void;
293
+ /**
294
+ * Trusted caller-side transform applied after the workflow's metadata has
295
+ * passed validation and before its body enters the worker VM.
296
+ */
297
+ transformScriptBody?(input: { script: string; body: string }): string;
285
298
  concurrency?: number;
286
299
  agentCap?: number;
287
300
  itemCap?: number;
@@ -357,7 +370,8 @@ function walk(value: unknown, path: string, seen: Set<object>): void {
357
370
  if (kind === "bigint") throw boundaryError("a BigInt", path);
358
371
  if (kind === "symbol") throw boundaryError("a symbol", path);
359
372
  if (kind === "function") throw boundaryError("a function", path);
360
- if (kind !== "object") throw boundaryError(`a ${kind}`, path);
373
+ // Every other `typeof` result is handled above; a new one fails `tsc` here.
374
+ kind satisfies "object";
361
375
 
362
376
  const object = value as object;
363
377
  if (seen.has(object)) throw boundaryError("a circular structure", path);
@@ -457,7 +471,7 @@ interface AgentCallPayload {
457
471
  type WorkerMessage =
458
472
  | { type: "call"; callId: number; method: string; payload: AgentCallPayload }
459
473
  | { type: "progress"; entries: WorkflowEntry[] }
460
- | { type: "complete"; resultJson?: string }
474
+ | { type: "complete"; resultJson?: string; coverage?: unknown }
461
475
  | { type: "error"; message: string; stack?: string };
462
476
 
463
477
  /** Everything below 0x20 except tab, newline and carriage return, plus DEL. */
@@ -595,7 +609,8 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
595
609
 
596
610
  assertBoundarySafe(options.args, "args");
597
611
 
598
- const { meta, body } = validateScript(script);
612
+ const { meta, body: validatedBody } = validateScript(script);
613
+ const body = options.transformScriptBody?.({ script, body: validatedBody }) ?? validatedBody;
599
614
  const agentCap = options.agentCap ?? WORKFLOW_AGENT_CAP;
600
615
  const itemCap = options.itemCap ?? WORKFLOW_ITEM_CAP;
601
616
  const semaphore = new Semaphore(options.concurrency ?? workflowConcurrency());
@@ -732,12 +747,12 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
732
747
  argsJson: options.args === undefined ? undefined : JSON.stringify(options.args),
733
748
  itemCap,
734
749
  nestedCap: options.nestedCap ?? WORKFLOW_NESTED_CAP,
750
+ collectCoverage: options.onScriptCoverage !== undefined,
735
751
  },
736
752
  });
737
753
 
738
754
  return await new Promise<WorkflowRunResult>(resolve => {
739
755
  const emit = (entries: WorkflowEntry[]) => {
740
- if (entries.length === 0) return;
741
756
  progress.push(...entries);
742
757
  options.onProgress?.(entries);
743
758
  };
@@ -1174,7 +1189,7 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
1174
1189
  respond(callId, true, {
1175
1190
  name: child.meta.name,
1176
1191
  metaJson: JSON.stringify(child.meta),
1177
- body: child.body,
1192
+ body: options.transformScriptBody?.({ script: source.script, body: child.body }) ?? child.body,
1178
1193
  });
1179
1194
  } catch (error) {
1180
1195
  respond(callId, false, undefined, error instanceof Error ? error.message : String(error));
@@ -1199,6 +1214,7 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
1199
1214
  void handleAgent(message.callId, message.payload as AgentCallPayload);
1200
1215
  break;
1201
1216
  case "complete": {
1217
+ if (message.coverage !== undefined) options.onScriptCoverage?.(message.coverage);
1202
1218
  // The script is done, so every launch it made should have been
1203
1219
  // answered by now — a response is sent before the worker can post
1204
1220
  // this, so anything still open was never awaited. finish() aborts
@@ -162,10 +162,8 @@ export function resolveWorkflowSource(
162
162
  try {
163
163
  return { ok: true, script: readFileSync(resolved, "utf-8"), path: resolved };
164
164
  } catch (err) {
165
- return {
166
- ok: false,
167
- message: `Could not read workflow script "${resolved}": ${err instanceof Error ? err.message : String(err)}`,
168
- };
165
+ // `readFileSync` only ever throws Node's own `Error`s.
166
+ return { ok: false, message: `Could not read workflow script "${resolved}": ${(err as Error).message}` };
169
167
  }
170
168
  }
171
169
  const name = ref.name?.trim();
@@ -78,6 +78,8 @@ export interface WorkflowTask {
78
78
  logs: string[];
79
79
 
80
80
  abortController: AbortController;
81
+ /** Set when the parent stopped the run with `stop_subagent`; unset for a human stop. */
82
+ stoppedBy?: "parent";
81
83
  startTime: number;
82
84
  endTime?: number;
83
85
  /** Excluded from the elapsed clock the header shows. */
@@ -184,19 +186,22 @@ export function resumeWorkflowTask(task: WorkflowTask, now = Date.now()): boolea
184
186
  if (task.status !== "paused" || task.control === undefined) return false;
185
187
  task.control.resume();
186
188
  task.status = "running";
187
- task.totalPausedMs = (task.totalPausedMs ?? 0) + Math.max(0, now - (task.pausedAt ?? now));
188
- task.pausedAt = undefined;
189
+ bankPause(task, now);
189
190
  return true;
190
191
  }
191
192
 
193
+ /** Close out an open pause into `totalPausedMs`; a no-op when the run is not held. */
194
+ function bankPause(task: WorkflowTask, now: number): void {
195
+ if (task.pausedAt === undefined) return;
196
+ task.totalPausedMs += Math.max(0, now - task.pausedAt);
197
+ task.pausedAt = undefined;
198
+ }
199
+
192
200
  /** Settle a task from the run's own result. */
193
201
  export function completeWorkflowTask(task: WorkflowTask, result: WorkflowRunResult): void {
194
202
  // Banked before the status moves off "paused": a run that finished while held
195
203
  // still spent that time held, and the elapsed figure has to say so.
196
- if (task.pausedAt !== undefined) {
197
- task.totalPausedMs = (task.totalPausedMs ?? 0) + Math.max(0, Date.now() - task.pausedAt);
198
- task.pausedAt = undefined;
199
- }
204
+ bankPause(task, Date.now());
200
205
  // Nothing left to control, and holding the handle would let the dialog offer
201
206
  // pause on a run that has already stopped.
202
207
  task.control = undefined;
@@ -293,7 +298,7 @@ export function formatWorkflowNotification(task: WorkflowTask, now = Date.now())
293
298
  const totals = stats(task.workflowProgress, task.agentCount);
294
299
  const status =
295
300
  task.status === "completed" ? "Done"
296
- : task.status === "killed" ? "Stopped"
301
+ : task.status === "killed" ? (task.stoppedBy === "parent" ? "Stopped by the parent with stop_subagent" : "Stopped")
297
302
  : `Error: ${task.error ?? "unknown"}`;
298
303
  const result = workflowResultText(task);
299
304
  return [
@@ -19,7 +19,7 @@
19
19
  * - `budget.total` is always null; pi has no token-target directive.
20
20
  * - `parallel` propagates a fatal run error instead of folding it to null.
21
21
  * - `effort` inherits the agent definition's level, then the parent's.
22
- * - `isolation` removes the worktree on settle, changes kept on a branch.
22
+ * - `isolation` removes the worktree on settle (kept if cleanup fails), changes kept on a branch.
23
23
  * Additions with no upstream counterpart: `gate`, `resume`, `effort: "minimal"`,
24
24
  * the saved-workflow directories, and the reject-unknown-options guarantee.
25
25
  *
@@ -77,7 +77,7 @@ Every script must begin with \`export const meta = {...}\`:
77
77
  The \`meta\` object must be a PURE LITERAL — no variables, function calls, spreads, or template interpolation. Required fields: \`name\`, \`description\`. Optional: \`whenToUse\` (shown in the workflow list), \`phases\`. Use the SAME phase titles in meta.phases as in phase() calls — titles are matched exactly; a phase() call with no matching meta entry just gets its own progress group. Add \`model\` to a phase entry when that phase uses a specific model override.
78
78
 
79
79
  Script body hooks:
80
- - agent(prompt: string, opts?: {label?: string, phase?: string, schema?: object, model?: string, effort?: string, isolation?: 'worktree', agentType?: string, gate?: string, resume?: string}): Promise<any> — spawn a subagent. Without schema, returns its final text as a string. With schema (a JSON Schema), the subagent is given a StructuredOutput tool built from it and agent() returns the validated object — no parsing needed. A payload that does not match is rejected back to the child, which corrects it; a child that never answers through the tool gets one more prompt and then fails, so the call returns null — filter after every schema stage. Returns null if the user skips the agent mid-run or the subagent dies on a terminal API error after retries (filter with .filter(Boolean)). opts.label overrides the display label. opts.phase explicitly assigns this agent to a progress group (use this inside pipeline()/parallel() stages to avoid races on the global phase() state — same phase string → same group box). opts.model overrides the model for this agent call. Default to omitting it — the agent inherits the main-loop model (the resolved session model), which is almost always correct. Only set it when you're highly confident a different tier fits the task; when unsure, omit. opts.effort overrides the reasoning effort for this agent call ('minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max') — omit to inherit the agent definition's own level, then the parent's; use 'low' for cheap mechanical stages and higher tiers only for the hardest verify/judge stages. opts.isolation: 'worktree' runs the agent in a fresh git worktree — EXPENSIVE (setup time + disk per agent), use ONLY when agents mutate files in parallel and would otherwise conflict; the worktree is removed when the agent settles, its changes preserved on a branch. opts.gate: '<command>' runs a shell command after the agent finishes and requires it to pass — a non-zero exit marks the agent failed and the command's output becomes the error; prefer gate: 'npm test' over asking another agent whether the code looks right. opts.resume: '<label>' continues the child that ran under that label instead of starting fresh, so an iterative loop keeps its context — it cannot be combined with agentType, model, effort, isolation, gate or schema. opts.agentType uses a custom subagent type instead of the default workflow subagent — resolved from the same registry as the Agent tool; composes with schema. Available types:
80
+ - agent(prompt: string, opts?: {label?: string, phase?: string, schema?: object, model?: string, effort?: string, isolation?: 'worktree', agentType?: string, gate?: string, resume?: string}): Promise<any> — spawn a subagent. Without schema, returns its final text as a string. With schema (a JSON Schema), the subagent is given a StructuredOutput tool built from it and agent() returns the validated object — no parsing needed. A payload that does not match is rejected back to the child, which corrects it; a child that never answers through the tool gets one more prompt and then fails, so the call returns null — filter after every schema stage. Returns null if the user skips the agent mid-run or the subagent dies on a terminal API error after retries (filter with .filter(Boolean)). opts.label overrides the display label. opts.phase explicitly assigns this agent to a progress group (use this inside pipeline()/parallel() stages to avoid races on the global phase() state — same phase string → same group box). opts.model overrides the model for this agent call. Default to omitting it so the configured selection policy applies: agentModels for the type, then agentModels["*"], then the agent definition, then the parent model. Only set it when you're highly confident a different tier fits the task; when unsure, omit. opts.effort overrides the reasoning effort for this agent call ('minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max') — omit to inherit the agent definition's own level, then the parent's; use 'low' for cheap mechanical stages and higher tiers only for the hardest verify/judge stages. opts.isolation: 'worktree' runs the agent in a fresh git worktree — EXPENSIVE (setup time + disk per agent), use ONLY when agents mutate files in parallel and would otherwise conflict; the worktree is removed when the agent settles, its changes preserved on a branch; if cleanup fails the worktree is kept and the agent errors. opts.gate: '<command>' runs a shell command after the agent finishes and requires it to pass — a non-zero exit marks the agent failed, returns null to the script, and records the command output on the workflow agent error; prefer gate: 'npm test' over asking another agent whether the code looks right. opts.resume: '<label>' continues the child that ran under that label instead of starting fresh, so an iterative loop keeps its context — it cannot be combined with agentType, model, effort, isolation, gate or schema. opts.agentType uses a custom subagent type instead of the default workflow subagent — resolved from the same registry as the Agent tool; composes with schema. Available types:
81
81
  {{typeList}}
82
82
  - pipeline(items, stage1, stage2, ...): Promise<any[]> — run each item through all stages independently, NO barrier between stages. Item A can be in stage 3 while item B is still in stage 1. This is the DEFAULT for multi-stage work. Wall-clock = slowest single-item chain, not sum-of-slowest-per-stage. Every stage callback receives (prevResult, originalItem, index) — use originalItem/index in later stages to label work without threading context through stage 1's return value. A stage that throws drops that item to \`null\` and skips its remaining stages.
83
83
  - parallel(thunks: Array<() => Promise<any>>): Promise<any[]> — run tasks concurrently. This is a BARRIER: awaits all thunks before returning. A thunk that throws (or whose agent errors) resolves to \`null\` in the result array, so \`.filter(Boolean)\` before using the results; only a fatal run error — a cap breach, or a nested workflow that could not load — propagates instead of being folded into a null. Use ONLY when you genuinely need all results together.
@@ -863,10 +863,14 @@ async function main() {
863
863
  const value = await run(rootScope.agent, rootScope.phase, rootScope.log, rootScope.workflow, rootScope.console, sandbox.args);
864
864
  checkBoundary(value, "the workflow result");
865
865
  flushProgress();
866
- port.postMessage({
866
+ const result = {
867
867
  type: "complete",
868
868
  resultJson: value === undefined ? undefined : JSON.stringify(value),
869
- });
869
+ };
870
+ if (workerData.collectCoverage) {
871
+ result.coverage = vm.runInContext("globalThis.__coverage__", context);
872
+ }
873
+ port.postMessage(result);
870
874
  }
871
875
 
872
876
  /** Serialize diagnostics only: keep realm errors untouched and bound cause traversal/output. */