pi-subagents 0.55.0 → 0.57.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 (97) hide show
  1. package/CHANGELOG.md +81 -3
  2. package/agents/claude-code-writer.md +15 -0
  3. package/agents/claude-code.md +15 -0
  4. package/agents/codex-exec-writer.md +15 -0
  5. package/agents/codex-exec.md +15 -0
  6. package/agents/cursor-agent-writer.md +14 -0
  7. package/agents/cursor-agent.md +14 -0
  8. package/docs/agents.md +111 -15
  9. package/docs/configuration.md +37 -0
  10. package/docs/extension-api.md +2 -2
  11. package/docs/models.md +6 -0
  12. package/docs/observability.md +8 -7
  13. package/docs/tool-reference.md +11 -1
  14. package/docs/workflows.md +20 -0
  15. package/package.json +1 -1
  16. package/skills/pi-subagents/references/execution-controls.md +1 -1
  17. package/src/agents/agent-management.ts +13 -5
  18. package/src/agents/agent-refinements.ts +4 -4
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +242 -49
  21. package/src/agents/builtin-names.ts +6 -0
  22. package/src/agents/runtime-agent-registry.ts +8 -2
  23. package/src/api/preflight.ts +22 -3
  24. package/src/extension/config.ts +70 -0
  25. package/src/extension/doctor.ts +3 -3
  26. package/src/extension/index.ts +18 -4
  27. package/src/extension/public-execution.ts +29 -13
  28. package/src/extension/rpc.ts +20 -5
  29. package/src/extension/schemas.ts +9 -3
  30. package/src/extension/tool-description.ts +11 -9
  31. package/src/inspectors/herdr/actions.ts +2 -1
  32. package/src/inspectors/herdr/inspector-runner.ts +2 -10
  33. package/src/inspectors/herdr/session-roots-codec.ts +42 -0
  34. package/src/integrations/herdr-status.ts +46 -3
  35. package/src/runs/background/active-async-capacity.ts +77 -10
  36. package/src/runs/background/async-execution.ts +148 -27
  37. package/src/runs/background/async-job-tracker.ts +5 -0
  38. package/src/runs/background/async-resume.ts +3 -0
  39. package/src/runs/background/async-retention.ts +20 -3
  40. package/src/runs/background/async-status.ts +5 -0
  41. package/src/runs/background/chain-root-attachment.ts +15 -1
  42. package/src/runs/background/fleet-view.ts +16 -10
  43. package/src/runs/background/inspect-rpc.ts +8 -8
  44. package/src/runs/background/result-files.ts +27 -14
  45. package/src/runs/background/result-watcher.ts +9 -5
  46. package/src/runs/background/run-status.ts +33 -6
  47. package/src/runs/background/scheduled-runs.ts +7 -1
  48. package/src/runs/background/subagent-runner.ts +254 -49
  49. package/src/runs/background/wait-completions.ts +4 -0
  50. package/src/runs/foreground/execution.ts +107 -16
  51. package/src/runs/foreground/foreground-control.ts +6 -0
  52. package/src/runs/foreground/foreground-history.ts +25 -3
  53. package/src/runs/foreground/subagent-executor.ts +324 -84
  54. package/src/runs/foreground/workflow-detach-reconcile.ts +31 -7
  55. package/src/runs/shared/acceptance.ts +10 -5
  56. package/src/runs/shared/agent-contract.ts +1 -1
  57. package/src/runs/shared/child-protocol.ts +21 -7
  58. package/src/runs/shared/claude-code-adapter.ts +129 -0
  59. package/src/runs/shared/codex-exec-adapter.ts +129 -0
  60. package/src/runs/shared/completion-guard.ts +3 -2
  61. package/src/runs/shared/cursor-agent-adapter.ts +114 -0
  62. package/src/runs/shared/dynamic-fanout.ts +1 -1
  63. package/src/runs/shared/extension-bindings.ts +78 -0
  64. package/src/runs/shared/external-cli-contract.ts +167 -0
  65. package/src/runs/shared/external-cli-preflight.ts +122 -0
  66. package/src/runs/shared/external-cli-runner.ts +349 -54
  67. package/src/runs/shared/fast-mode-extension.ts +10 -0
  68. package/src/runs/shared/model-exclusions.ts +59 -8
  69. package/src/runs/shared/model-fallback.ts +25 -8
  70. package/src/runs/shared/mutation-evidence.ts +150 -0
  71. package/src/runs/shared/nested-events.ts +3 -1
  72. package/src/runs/shared/nested-render.ts +2 -2
  73. package/src/runs/shared/parallel-utils.ts +3 -0
  74. package/src/runs/shared/pi-args.ts +47 -0
  75. package/src/runs/shared/process-signal.ts +13 -0
  76. package/src/runs/shared/run-history.ts +21 -1
  77. package/src/runs/shared/structured-output.ts +18 -4
  78. package/src/runs/shared/subagent-prompt-runtime.ts +9 -5
  79. package/src/shared/fork-context.ts +21 -0
  80. package/src/shared/formatters.ts +7 -1
  81. package/src/shared/launch-contract.ts +6 -0
  82. package/src/shared/pruned-fork.ts +450 -0
  83. package/src/shared/session-file-trust.ts +19 -0
  84. package/src/shared/session-tokens.ts +14 -3
  85. package/src/shared/settings.ts +16 -3
  86. package/src/shared/shortcuts.ts +17 -0
  87. package/src/shared/types.ts +166 -9
  88. package/src/shared/workflow-child-permit.ts +116 -0
  89. package/src/slash/delegation-adapters.ts +0 -1
  90. package/src/slash/slash-commands.ts +8 -6
  91. package/src/tui/fleet-status.ts +27 -10
  92. package/src/tui/fleet-transcript.ts +11 -5
  93. package/src/tui/fleet.ts +28 -13
  94. package/src/tui/render.ts +47 -21
  95. package/src/workflows/scripted-workflow.ts +298 -30
  96. package/src/workflows/workflow-child-summary.ts +117 -0
  97. package/src/workflows/workflow-receipt.ts +119 -4
@@ -6,6 +6,17 @@ import { Worker } from "node:worker_threads";
6
6
  const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
7
7
  const requireFromPackage = createRequire(import.meta.url);
8
8
 
9
+ export interface WorkflowScriptValidationError {
10
+ message: string;
11
+ line?: number;
12
+ column?: number;
13
+ }
14
+
15
+ export interface WorkflowScriptValidationResult {
16
+ ok: boolean;
17
+ errors: WorkflowScriptValidationError[];
18
+ }
19
+
9
20
  const WORKER_SOURCE = String.raw`
10
21
  const { parentPort, workerData } = require("node:worker_threads");
11
22
  const vm = require("node:vm");
@@ -150,7 +161,15 @@ function trackObservationTracker(tracker, promise, allowFutureObservations = fal
150
161
  get(promiseTarget, prop) {
151
162
  if (prop === "then") return function promiseThen(onFulfilled, onRejected) {
152
163
  consumeTrackedObservations(tracker);
153
- return trackObservationTracker({ observations: tracker.observations, consumed: false, dependencies: [tracker] }, promiseTarget.then(onFulfilled, onRejected), true);
164
+ const chainTracker = { observations: tracker.observations, consumed: false, dependencies: [tracker] };
165
+ const wrapHandler = (handler) => typeof handler === "function"
166
+ ? function trackedThenHandler(...args) {
167
+ const value = handler.apply(this, args);
168
+ addTrackerDependency(chainTracker, promiseObservationTracker(value));
169
+ return trackedPromiseTarget(value);
170
+ }
171
+ : handler;
172
+ return trackObservationTracker(chainTracker, promiseTarget.then(wrapHandler(onFulfilled), wrapHandler(onRejected)), true);
154
173
  };
155
174
  if (prop === "catch") return function promiseCatch(onRejected) {
156
175
  consumeTrackedObservations(tracker);
@@ -276,8 +295,43 @@ function formatRef(result) {
276
295
  return "[" + parts.join("; ") + "]";
277
296
  }
278
297
 
298
+ function formatChildResultString(result) {
299
+ const output = typeof result?.output === "string" ? result.output.trim() : "";
300
+ return output || formatRef(result);
301
+ }
302
+
303
+ function decorateWorkflowChildResult(result) {
304
+ if (!result || typeof result !== "object" || Array.isArray(result)) return result;
305
+ Object.defineProperties(result, {
306
+ toString: { value() { return formatChildResultString(this); }, enumerable: false, configurable: true },
307
+ });
308
+ return result;
309
+ }
310
+
279
311
  let runFingerprints = new Map();
280
312
 
313
+ function validateExtensionBindings(value, label) {
314
+ if (value === undefined) return;
315
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(label + " extensionBindings must be a plain JSON object.");
316
+ const keys = Object.keys(value);
317
+ if (keys.length > 16) throw new Error(label + " extensionBindings supports at most 16 namespaces.");
318
+ for (const key of keys) if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62})\/[1-9][0-9]{0,8}$/.test(key)) throw new Error(label + " extensionBindings namespace '" + key + "' must use a package-like name followed by '/<positive-version>'.");
319
+ assertJsonValue(value, label + " extensionBindings");
320
+ let propertyCount = 0;
321
+ function visit(entry, depth) {
322
+ if (!entry || typeof entry !== "object") return;
323
+ if (depth > 16) throw new Error(label + " extensionBindings exceeds the maximum nesting depth of 16.");
324
+ if (Array.isArray(entry)) { for (const item of entry) visit(item, depth + 1); return; }
325
+ for (const child of Object.values(entry)) {
326
+ propertyCount++;
327
+ if (propertyCount > 256) throw new Error(label + " extensionBindings exceeds 256 total properties.");
328
+ visit(child, depth + 1);
329
+ }
330
+ }
331
+ visit(value, 0);
332
+ if (new TextEncoder().encode(stableRunJson(value)).byteLength > 16384) throw new Error(label + " extensionBindings canonical JSON exceeds 16384 bytes.");
333
+ }
334
+
281
335
  function validateRunCall(key, params, label, fingerprints) {
282
336
  if (typeof key !== "string" || !runKeyPattern.test(key)) throw new Error(label + " has an invalid key.");
283
337
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error(label + " requires a params object.");
@@ -290,6 +344,7 @@ function validateRunCall(key, params, label, fingerprints) {
290
344
  if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) throw new Error(label + " gate must be a non-empty command string.");
291
345
  if (params.gate !== undefined && params.acceptance !== undefined) throw new Error(label + " gate cannot be combined with acceptance; use one gate command or acceptance.verify.");
292
346
  if (params.gate !== undefined && params.resume !== undefined) throw new Error(label + " gate is not supported with retained resume.");
347
+ if (params.extensionBindings !== undefined && params.resume !== undefined) throw new Error(label + " extensionBindings is not supported with retained resume; resume uses the original retained child binding.");
293
348
  if (params.resume !== undefined && typeof params.resume !== "string") {
294
349
  const reference = params.resume;
295
350
  if (!reference || typeof reference !== "object" || Array.isArray(reference)) throw new Error(label + " resume must be a retained run id or keyed workflow receipt reference.");
@@ -302,6 +357,7 @@ function validateRunCall(key, params, label, fingerprints) {
302
357
  if (typeof params.resume === "string" && !params.resume.trim()) throw new Error(label + " resume must be a non-empty retained run id.");
303
358
  if (params.resume !== undefined && params.agent !== undefined) throw new Error(label + " resume and agent are mutually exclusive.");
304
359
  if (params.resume !== undefined && (typeof params.task !== "string" || !params.task.trim())) throw new Error(label + " resume requires a non-empty task follow-up.");
360
+ validateExtensionBindings(params.extensionBindings, label);
305
361
  assertJsonValue(params, label + " params");
306
362
  const fingerprint = stableRunJson(params);
307
363
  const existing = fingerprints.get(key);
@@ -312,7 +368,8 @@ function validateRunCall(key, params, label, fingerprints) {
312
368
  const runs = Object.freeze({
313
369
  run(key, params) {
314
370
  validateRunCall(key, params, "runs.run", runFingerprints);
315
- return hostCall("run", { key, params }, { key, operation: "run" });
371
+ const launched = runHostCall(key, params, false);
372
+ return trackRunObservation([{ key, operation: "run", callId: launched.callId }], launched.promise.then(decorateWorkflowChildResult));
316
373
  },
317
374
  all(items) {
318
375
  if (!Array.isArray(items)) throw new Error("runs.all(items) requires an array.");
@@ -329,7 +386,7 @@ const runs = Object.freeze({
329
386
  runFingerprints = fingerprints;
330
387
  const batch = { id: "batch-" + (++nextCallId), calls };
331
388
  const launched = calls.map(({ key, params }) => runHostCall(key, params, true, batch));
332
- return trackRunObservation(launched.map(({ key, callId }) => ({ key, operation: "run", callId })), Promise.all(launched.map(({ promise }) => promise)).then((results) => wrapRunsAllResults(results, calls.map(({ key }) => key))));
389
+ return trackRunObservation(launched.map(({ key, callId }) => ({ key, operation: "run", callId })), Promise.all(launched.map(({ promise }) => promise)).then((results) => wrapRunsAllResults(results.map(decorateWorkflowChildResult), calls.map(({ key }) => key))));
333
390
  },
334
391
  steer(key, message, options = {}) {
335
392
  if (typeof key !== "string" || !runKeyPattern.test(key)) throw new Error("runs.steer has an invalid key.");
@@ -394,7 +451,7 @@ function isSyntaxError(error) {
394
451
  return error instanceof SyntaxError || error?.name === "SyntaxError";
395
452
  }
396
453
 
397
- const NESTED_ASYNC_WORKFLOW_ERROR = "workflowScript does not support nested async functions. Use top-level await, plain helper functions that return runs.run(...), or explicit Promise chains so workflows stay portable across Node and Bun.";
454
+ const NESTED_ASYNC_WORKFLOW_ERROR = "workflowScript validation failed before child launch; no children launched. workflowScript does not support nested async functions. Use top-level await, plain helper functions that return runs.run(...), or explicit Promise chains so workflows stay portable across Node and Bun. Parallel plus sequential rewrite: const a = runs.run(\"a\", { agent: \"worker\", task: \"A\" }); const writer = await runs.run(\"writer\", { agent: \"worker\", task: \"Write\" }); const review = await runs.run(\"review\", { agent: \"reviewer\", task: writer.output }); const [aResult] = await Promise.all([a]); return { a: aResult.output, issue: { writerRunId: writer.runId, reviewRunId: review.runId } };";
398
455
  const AST_SCALAR_KEYS = new Set(["type", "start", "end"]);
399
456
 
400
457
  function assertPortableWorkflowScript(source) {
@@ -581,7 +638,12 @@ parentPort.on("message", async (message) => {
581
638
  }
582
639
  }
583
640
  const persistedValue = value === undefined ? null : omitUndefinedWorkflowValues(value);
584
- assertJsonValue(persistedValue, "return");
641
+ try {
642
+ assertJsonValue(persistedValue, "return");
643
+ } catch (error) {
644
+ parentPort.postMessage({ type: "error", errorPhase: "return-serialization", error: formatWorkflowScriptError(error) });
645
+ return;
646
+ }
585
647
  parentPort.postMessage({ type: "complete", value: persistedValue });
586
648
  } catch (error) {
587
649
  parentPort.postMessage({ type: "error", error: isSyntaxError(error) ? formatWorkflowScriptSyntaxError(error) : formatWorkflowScriptError(error), ...(error && error.workflowErrorKind === "detached-child" ? { errorKind: "detached-child" } : {}) });
@@ -599,10 +661,12 @@ export interface WorkflowScriptChildResult {
599
661
  output: string;
600
662
  error?: string;
601
663
  detached?: boolean;
664
+ interrupted?: boolean;
602
665
  structuredOutput?: unknown;
603
666
  requestedContext?: "fresh" | "fork";
604
667
  resolvedContext?: "fresh" | "fork" | "mixed";
605
668
  outputReference?: string;
669
+ externalAdapter?: import("../shared/types.ts").ExternalCliReceiptMetadata;
606
670
  resumability?: { state: "resumable" } | { state: "not-resumable"; reason: string };
607
671
  continuation?: { runIds: string[] };
608
672
  artifactPaths: string[];
@@ -670,10 +734,12 @@ export class WorkflowScriptError extends Error {
670
734
 
671
735
  export interface RunWorkflowScriptOptions {
672
736
  script: string;
737
+ /** Host-only first-slice admission context. It is never sent to the workflow worker. */
738
+ oneUsePermit?: { claim: (key: string) => string | undefined };
673
739
  timeoutMs?: number;
674
740
  signal?: AbortSignal;
675
741
  admit?: (calls: Array<{ key: string; params: Record<string, unknown> }>) => void | Promise<void>;
676
- launch: (key: string, params: Record<string, unknown>, signal: AbortSignal, admission: { admitted: boolean }) => Promise<WorkflowScriptChildResult>;
742
+ launch: (key: string, params: Record<string, unknown>, signal: AbortSignal, admission: { admitted: boolean; batch: boolean }) => Promise<WorkflowScriptChildResult>;
677
743
  resolveResume?: (reference: WorkflowReceiptResumeReference, signal: AbortSignal) => string | WorkflowResolvedResumeReference | Promise<string | WorkflowResolvedResumeReference>;
678
744
  status: (keyOrRunId: string, signal: AbortSignal) => Promise<WorkflowScriptChildResult>;
679
745
  steer?: (key: string, message: string, options: WorkflowSteerOptions, signal: AbortSignal) => Promise<WorkflowSteerResult>;
@@ -780,6 +846,15 @@ export function formatWorkflowJsonPreview(value: unknown, maxLength: number): st
780
846
  }
781
847
  }
782
848
 
849
+ function workflowReturnRecoveryHint(children: WorkflowScriptChildResult[]): string {
850
+ if (children.length === 0) return " Return only plain JSON data. For a child result, select fields such as { runId: child.runId, ok: child.ok, outputReference: child.outputReference }.";
851
+ const references = children.slice(0, 10).map((child) => {
852
+ const fields = [child.runId ? `runId=${child.runId.slice(0, 500)}` : undefined, child.outputReference ? `outputReference=${child.outputReference.slice(0, 500)}` : undefined, child.artifactPaths[0] ? `artifact=${child.artifactPaths[0].slice(0, 500)}` : undefined].filter((field): field is string => field !== undefined);
853
+ return `'${child.key}'${fields.length > 0 ? ` (${fields.join(", ")})` : ""}`;
854
+ });
855
+ return ` Child work completed before return serialization failed. Recover outputs from: ${references.join(", ")}${children.length > references.length ? `, and ${children.length - references.length} more` : ""}. Return a plain projection such as { runId: child.runId, ok: child.ok, outputReference: child.outputReference }.`;
856
+ }
857
+
783
858
  export interface SimpleWorkflowRunPreview {
784
859
  agent?: string;
785
860
  task?: string;
@@ -817,6 +892,194 @@ function validateKey(value: unknown, owner = "runs.run"): string {
817
892
  return value;
818
893
  }
819
894
 
895
+ type AstNode = {
896
+ type: string;
897
+ loc?: { start: { line: number; column: number } };
898
+ [key: string]: unknown;
899
+ };
900
+
901
+ const AST_LOCATION_KEYS = new Set(["type", "start", "end", "loc", "range"]);
902
+
903
+ function astNode(value: unknown): value is AstNode {
904
+ return Boolean(value) && typeof value === "object" && typeof (value as { type?: unknown }).type === "string";
905
+ }
906
+
907
+ function literalString(node: unknown): string | undefined {
908
+ if (!astNode(node)) return undefined;
909
+ if (node.type === "Literal" && typeof node.value === "string") return node.value;
910
+ if (node.type === "TemplateLiteral" && Array.isArray(node.expressions) && node.expressions.length === 0 && Array.isArray(node.quasis)) {
911
+ const first = node.quasis[0] as { value?: { cooked?: unknown } } | undefined;
912
+ if (typeof first?.value?.cooked === "string") return first.value.cooked;
913
+ }
914
+ return undefined;
915
+ }
916
+
917
+ function directRunsCall(node: unknown, method: "run" | "all"): node is AstNode {
918
+ if (!astNode(node) || node.type !== "CallExpression" || !astNode(node.callee) || node.callee.type !== "MemberExpression") return false;
919
+ const property = node.callee.computed === true ? literalString(node.callee.property) : astNode(node.callee.property) && node.callee.property.type === "Identifier" ? node.callee.property.name : undefined;
920
+ return property === method && astNode(node.callee.object) && node.callee.object.type === "Identifier" && node.callee.object.name === "runs";
921
+ }
922
+
923
+ function nodeLocation(node: AstNode): Pick<WorkflowScriptValidationError, "line" | "column"> {
924
+ return node.loc ? { line: Math.max(1, node.loc.start.line - 1), column: node.loc.start.column + 1 } : {};
925
+ }
926
+
927
+ function walkAst(node: unknown, visit: (node: AstNode) => void, includeNestedFunctions = true): void {
928
+ if (Array.isArray(node)) {
929
+ for (const item of node) walkAst(item, visit, includeNestedFunctions);
930
+ return;
931
+ }
932
+ if (!astNode(node)) return;
933
+ visit(node);
934
+ if (!includeNestedFunctions && (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression")) return;
935
+ for (const [key, child] of Object.entries(node)) {
936
+ if (!AST_LOCATION_KEYS.has(key)) walkAst(child, visit, includeNestedFunctions);
937
+ }
938
+ }
939
+
940
+ function definitelyNonJson(node: AstNode, normalizeUndefined = false): string | undefined {
941
+ if (node.type === "Literal") {
942
+ if (typeof node.bigint === "string") return "BigInt values are not JSON-representable";
943
+ if (node.regex !== undefined) return "regular expressions are not JSON-representable";
944
+ return undefined;
945
+ }
946
+ if (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") return "functions are not JSON-representable";
947
+ if (node.type === "Identifier" && node.name === "undefined") return normalizeUndefined ? undefined : "undefined is not JSON-representable";
948
+ if (node.type === "UnaryExpression" && node.operator === "void") return normalizeUndefined ? undefined : "undefined is not JSON-representable";
949
+ if (node.type === "ArrayExpression" && Array.isArray(node.elements)) {
950
+ if (node.elements.some((entry) => entry === null)) return "sparse arrays are not JSON-representable";
951
+ for (const entry of node.elements) if (astNode(entry)) {
952
+ const error = definitelyNonJson(entry, normalizeUndefined);
953
+ if (error) return error;
954
+ }
955
+ }
956
+ if (node.type === "ObjectExpression" && Array.isArray(node.properties)) {
957
+ const values = new Map<string, AstNode>();
958
+ for (const property of node.properties) {
959
+ if (!astNode(property) || property.type !== "Property" || !astNode(property.value)) return undefined;
960
+ const key = staticPropertyKey(property);
961
+ if (key === undefined) return undefined;
962
+ values.set(key, property.value);
963
+ }
964
+ for (const value of values.values()) {
965
+ const error = definitelyNonJson(value, normalizeUndefined);
966
+ if (error) return error;
967
+ }
968
+ }
969
+ return undefined;
970
+ }
971
+
972
+ function staticPropertyKey(property: AstNode): string | undefined {
973
+ return property.computed === true
974
+ ? literalString(property.key)
975
+ : literalString(property.key) ?? (astNode(property.key) && property.key.type === "Identifier" ? property.key.name as string : undefined);
976
+ }
977
+
978
+ function directObjectPropertyValue(node: AstNode, name: string): AstNode | undefined {
979
+ if (node.type !== "ObjectExpression" || !Array.isArray(node.properties)) return undefined;
980
+ let value: AstNode | undefined;
981
+ for (const property of node.properties) {
982
+ if (!astNode(property) || property.type !== "Property" || !astNode(property.value)) continue;
983
+ if (staticPropertyKey(property) === name) value = property.value;
984
+ }
985
+ return value;
986
+ }
987
+
988
+ function directRunsAllKeys(call: AstNode): Array<{ key: string; node: AstNode }> {
989
+ const args = Array.isArray(call.arguments) ? call.arguments : [];
990
+ const items = astNode(args[0]) && args[0].type === "ArrayExpression" && Array.isArray(args[0].elements) ? args[0].elements : [];
991
+ return items.flatMap((item) => {
992
+ if (!astNode(item)) return [];
993
+ const keyNode = directObjectPropertyValue(item, "key");
994
+ const key = literalString(keyNode);
995
+ return keyNode && key !== undefined ? [{ key, node: keyNode }] : [];
996
+ });
997
+ }
998
+
999
+ /** Parse a workflowScript and apply only rules that are decidable from its local syntax. */
1000
+ export function validateWorkflowScript(script: string): WorkflowScriptValidationResult {
1001
+ const errors: WorkflowScriptValidationError[] = [];
1002
+ if (!script.trim()) return { ok: false, errors: [{ message: "workflowScript must not be empty." }] };
1003
+ let root: AstNode;
1004
+ try {
1005
+ const parser = requireFromPackage(resolveWorkflowParserEntry()) as { parse(source: string, options: Record<string, unknown>): unknown };
1006
+ root = parser.parse(`(async () => {\n${script}\n})()`, { ecmaVersion: "latest", sourceType: "script", locations: true }) as AstNode;
1007
+ } catch (error) {
1008
+ const location = error && typeof error === "object" && "loc" in error && error.loc && typeof error.loc === "object"
1009
+ ? error.loc as { line?: unknown; column?: unknown }
1010
+ : undefined;
1011
+ const message = (error instanceof Error ? error.message : String(error)).replace(/\s+\(\d+:\d+\)$/, "");
1012
+ return { ok: false, errors: [{ message, ...(typeof location?.line === "number" ? { line: Math.max(1, location.line - 1) } : {}), ...(typeof location?.column === "number" ? { column: location.column + 1 } : {}) }] };
1013
+ }
1014
+
1015
+ const wrapper = astNode(root.body) ? undefined : Array.isArray(root.body) && astNode(root.body[0]) && astNode(root.body[0].expression) && astNode(root.body[0].expression.callee)
1016
+ ? root.body[0].expression.callee
1017
+ : undefined;
1018
+ const workflowBody = wrapper && astNode(wrapper.body) ? wrapper.body : root;
1019
+ walkAst(workflowBody, (node) => {
1020
+ if (node !== wrapper && node.async === true && (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression")) {
1021
+ errors.push({ message: "workflowScript does not support nested async functions. Use top-level await, plain helper functions that return runs.run(...), or explicit Promise chains.", ...nodeLocation(node) });
1022
+ }
1023
+ if (directRunsCall(node, "run")) {
1024
+ const args = Array.isArray(node.arguments) ? node.arguments : [];
1025
+ const keyNode = astNode(args[0]) ? args[0] : undefined;
1026
+ const key = literalString(keyNode);
1027
+ if (keyNode && key !== undefined && !KEY_PATTERN.test(key)) errors.push({ message: "runs.run key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.", ...nodeLocation(keyNode) });
1028
+ if (astNode(args[1])) {
1029
+ const message = definitelyNonJson(args[1]);
1030
+ if (message) errors.push({ message: `runs.run params are invalid: ${message}.`, ...nodeLocation(args[1]) });
1031
+ }
1032
+ }
1033
+ if (directRunsCall(node, "all")) {
1034
+ for (const entry of directRunsAllKeys(node)) if (!KEY_PATTERN.test(entry.key)) errors.push({ message: "runs.all item key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.", ...nodeLocation(entry.node) });
1035
+ const args = Array.isArray(node.arguments) ? node.arguments : [];
1036
+ if (astNode(args[0]) && args[0].type === "ArrayExpression" && Array.isArray(args[0].elements)) {
1037
+ for (const item of args[0].elements) if (astNode(item)) {
1038
+ const message = definitelyNonJson(item);
1039
+ if (message) errors.push({ message: `runs.all item params are invalid: ${message}.`, ...nodeLocation(item) });
1040
+ }
1041
+ }
1042
+ }
1043
+ const boundaryValue = node.type === "CallExpression" && astNode(node.callee) && node.callee.type === "Identifier" && node.callee.name === "emit" && Array.isArray(node.arguments) && astNode(node.arguments[0])
1044
+ ? node.arguments[0]
1045
+ : node.type === "CallExpression" && astNode(node.callee) && node.callee.type === "MemberExpression" && astNode(node.callee.object) && node.callee.object.type === "Identifier" && node.callee.object.name === "state" && astNode(node.callee.property) && node.callee.property.type === "Identifier" && node.callee.property.name === "set" && Array.isArray(node.arguments) && astNode(node.arguments[1])
1046
+ ? node.arguments[1]
1047
+ : undefined;
1048
+ if (boundaryValue) {
1049
+ const message = definitelyNonJson(boundaryValue);
1050
+ if (message) errors.push({ message: `workflowScript boundary value is invalid: ${message}.`, ...nodeLocation(boundaryValue) });
1051
+ }
1052
+ });
1053
+ walkAst(workflowBody, (node) => {
1054
+ if (node.type !== "ReturnStatement" || !astNode(node.argument)) return;
1055
+ const message = definitelyNonJson(node.argument, true);
1056
+ if (message) errors.push({ message: `workflowScript boundary value is invalid: ${message}.`, ...nodeLocation(node.argument) });
1057
+ }, false);
1058
+
1059
+ if (workflowBody.type === "BlockStatement" && Array.isArray(workflowBody.body)) {
1060
+ for (let statementIndex = 0; statementIndex < workflowBody.body.length; statementIndex++) {
1061
+ const statement = workflowBody.body[statementIndex];
1062
+ if (!astNode(statement) || statement.type !== "VariableDeclaration" || !Array.isArray(statement.declarations)) continue;
1063
+ for (const declaration of statement.declarations) {
1064
+ if (!astNode(declaration) || !astNode(declaration.id) || declaration.id.type !== "Identifier" || !astNode(declaration.init) || declaration.init.type !== "AwaitExpression" || !directRunsCall(declaration.init.argument, "all")) continue;
1065
+ const name = declaration.id.name as string;
1066
+ const keys = new Set(directRunsAllKeys(declaration.init.argument).map((entry) => entry.key));
1067
+ if (keys.size === 0) continue;
1068
+ const args = Array.isArray(declaration.init.argument.arguments) ? declaration.init.argument.arguments : [];
1069
+ const itemCount = astNode(args[0]) && args[0].type === "ArrayExpression" && Array.isArray(args[0].elements) ? args[0].elements.length : 0;
1070
+ const arrayResultShape = Array.from({ length: itemCount });
1071
+ for (const later of workflowBody.body.slice(statementIndex + 1)) walkAst(later, (node) => {
1072
+ if (node.type !== "MemberExpression" || !astNode(node.object) || node.object.type !== "Identifier" || node.object.name !== name) return;
1073
+ const property = node.computed === true ? literalString(node.property) : astNode(node.property) && node.property.type === "Identifier" ? node.property.name as string : undefined;
1074
+ if (property && keys.has(property) && !(property in arrayResultShape)) errors.push({ message: `runs.all returns an ordered array; '${name}.${property}' is keyed access. Use an index, destructuring, or map(...).`, ...nodeLocation(node) });
1075
+ }, false);
1076
+ }
1077
+ }
1078
+ }
1079
+
1080
+ const unique = errors.filter((error, index) => errors.findIndex((candidate) => candidate.message === error.message && candidate.line === error.line && candidate.column === error.column) === index);
1081
+ return { ok: unique.length === 0, errors: unique };
1082
+ }
820
1083
  function workflowStringMetadata(params: Record<string, unknown>): Pick<WorkflowScriptTraceEntry, "phase" | "label" | "agent"> {
821
1084
  return {
822
1085
  ...(typeof params.phase === "string" && params.phase.trim() ? { phase: params.phase.trim() } : {}),
@@ -1002,7 +1265,9 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1002
1265
  return finish({ value: message.value });
1003
1266
  }
1004
1267
  if (message.type === "error") {
1005
- const workflowError = new Error(typeof message.error === "string" ? message.error : "Workflow script failed.") as Error & { workflowErrorKind?: "detached-child" };
1268
+ const rawError = typeof message.error === "string" ? message.error : "Workflow script failed.";
1269
+ const text = message.errorPhase === "return-serialization" ? `${rawError}${workflowReturnRecoveryHint(partial().children)}` : rawError;
1270
+ const workflowError = new Error(text) as Error & { workflowErrorKind?: "detached-child" };
1006
1271
  if (message.errorKind === "detached-child") workflowError.workflowErrorKind = "detached-child";
1007
1272
  return finish({ error: workflowError });
1008
1273
  }
@@ -1118,6 +1383,31 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1118
1383
  }
1119
1384
  const params = message.args.params;
1120
1385
  if (!isRecord(params)) return respond(Promise.reject(new Error(`runs.run('${key}', params) requires a params object.`)));
1386
+ const collectFailure = message.args.collectFailure === true;
1387
+ const callObserved = observedRunCalls.delete(message.callId);
1388
+ const deliver = (promise: Promise<WorkflowScriptChildResult>) => collectFailure
1389
+ ? promise
1390
+ : promise.then((result) => {
1391
+ if (!result.ok && !result.stopped) {
1392
+ const childError = new Error(result.detached ? `Run '${key}' detached: ${result.error ?? result.output}` : `Run '${key}' failed: ${result.error ?? result.output}`) as Error & { workflowErrorKind?: "detached-child" };
1393
+ if (result.detached) childError.workflowErrorKind = "detached-child";
1394
+ throw childError;
1395
+ }
1396
+ return result;
1397
+ });
1398
+ const fingerprint = stableJson(params);
1399
+ const existing = launches.get(key);
1400
+ if (existing) {
1401
+ if (existing.fingerprint !== fingerprint) return respond(Promise.reject(new Error(`Duplicate workflow key '${key}' used with incompatible launch params.`)));
1402
+ if (callObserved) existing.observed = true;
1403
+ trace.push({ operation: "run", key, state: "reused", ...workflowStringMetadata(params) });
1404
+ traceChanged();
1405
+ return respond(deliver(existing.promise), `runs.run('${key}') result`);
1406
+ }
1407
+ const permitError = options.oneUsePermit?.claim(key);
1408
+ if (permitError) return respond(Promise.reject(new Error(permitError)));
1409
+ if (options.oneUsePermit && message.args.batch !== undefined) return respond(Promise.reject(new Error("Workflow child permit does not support runs.all.")));
1410
+ if (options.oneUsePermit && params.resume !== undefined) return respond(Promise.reject(new Error("Workflow child permit does not support retained resume.")));
1121
1411
  if (params.action !== undefined) return respond(Promise.reject(new Error(`runs.run('${key}') accepts execution params only; management action is not allowed.`)));
1122
1412
  if (params.workflowScript !== undefined) return respond(Promise.reject(new Error(`runs.run('${key}') cannot start a nested workflow script.`)));
1123
1413
  if (params.tasks !== undefined || params.chain !== undefined || params.parallel !== undefined || params.concurrency !== undefined || params.chainDir !== undefined) {
@@ -1148,28 +1438,6 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1148
1438
  if (params.resume !== undefined && (typeof params.task !== "string" || !params.task.trim())) {
1149
1439
  return respond(Promise.reject(new Error(`runs.run('${key}') resume requires a non-empty task follow-up.`)));
1150
1440
  }
1151
- const collectFailure = message.args.collectFailure === true;
1152
- const callObserved = observedRunCalls.delete(message.callId);
1153
- const deliver = (promise: Promise<WorkflowScriptChildResult>) => collectFailure
1154
- ? promise
1155
- : promise.then((result) => {
1156
- if (!result.ok && !result.stopped) {
1157
- const childError = new Error(result.detached ? `Run '${key}' detached: ${result.error ?? result.output}` : `Run '${key}' failed: ${result.error ?? result.output}`) as Error & { workflowErrorKind?: "detached-child" };
1158
- if (result.detached) childError.workflowErrorKind = "detached-child";
1159
- throw childError;
1160
- }
1161
- return result;
1162
- });
1163
- const fingerprint = stableJson(params);
1164
- const existing = launches.get(key);
1165
- if (existing) {
1166
- if (existing.fingerprint !== fingerprint) return respond(Promise.reject(new Error(`Duplicate workflow key '${key}' used with incompatible launch params.`)));
1167
- if (callObserved) existing.observed = true;
1168
- trace.push({ operation: "run", key, state: "reused", ...workflowStringMetadata(params) });
1169
- traceChanged();
1170
- return respond(deliver(existing.promise), `runs.run('${key}') result`);
1171
- }
1172
-
1173
1441
  const startedAt = Date.now();
1174
1442
  const batch = isRecord(message.args.batch) && typeof message.args.batch.id === "string" && Array.isArray(message.args.batch.calls)
1175
1443
  ? { id: message.args.batch.id, calls: message.args.batch.calls.filter((call): call is { key: string; params: Record<string, unknown> } => isRecord(call) && typeof call.key === "string" && isRecord(call.params)) }
@@ -1219,7 +1487,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1219
1487
  if (resolvedResumeLineage.at(-1) !== resolvedResumeId) resolvedResumeLineage.push(resolvedResumeId!);
1220
1488
  }
1221
1489
  const launchParams = resolvedResumeId ? { ...params, resume: resolvedResumeId } : params;
1222
- return options.launch(key, launchParams, childSignal, { admitted: true });
1490
+ return options.launch(key, launchParams, childSignal, { admitted: true, batch: batch !== undefined });
1223
1491
  }).then((result) => {
1224
1492
  let normalized = !result.ok && !result.error ? { ...result, error: result.output } : result;
1225
1493
  if (resolvedResumeLineage?.length && normalized.runId) {
@@ -0,0 +1,117 @@
1
+ import type { AsyncStatus, WorkflowChildSummaryV1 } from "../shared/types.ts";
2
+ import type { WorkflowScriptChildResult, WorkflowScriptTraceEntry } from "./scripted-workflow.ts";
3
+
4
+ const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
5
+ const TERMINAL_STATES = new Set(["completed", "failed", "paused", "stopped", "rejected", "detached"]);
6
+
7
+ function bounded(value: unknown, maxBytes: number): string | undefined {
8
+ if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value, "utf8") > maxBytes) return undefined;
9
+ return value;
10
+ }
11
+
12
+ function requiredId(value: string, label: string): string {
13
+ const id = bounded(value, 256);
14
+ if (!id) throw new Error(`${label} must be a non-empty identifier of at most 256 UTF-8 bytes.`);
15
+ return id;
16
+ }
17
+
18
+ export function workflowChildSummary(input: {
19
+ parentToolCallId: string;
20
+ workflowRunId: string;
21
+ workflowState: WorkflowChildSummaryV1["workflowState"];
22
+ inventoryComplete: boolean;
23
+ trace?: WorkflowScriptTraceEntry[];
24
+ children?: WorkflowScriptChildResult[];
25
+ steps?: NonNullable<AsyncStatus["steps"]>;
26
+ }): WorkflowChildSummaryV1 {
27
+ const rows = new Map<string, WorkflowChildSummaryV1["children"][number]>();
28
+ for (const entry of input.trace ?? []) {
29
+ if (entry.operation !== "run" || !KEY_PATTERN.test(entry.key)) continue;
30
+ const previous = rows.get(entry.key);
31
+ const state = entry.state === "completed" ? "completed"
32
+ : entry.state === "failed" ? "failed"
33
+ : entry.state === "stopped" ? "stopped"
34
+ : entry.state === "detached" ? "detached"
35
+ : previous?.state ?? "running";
36
+ rows.set(entry.key, {
37
+ childId: entry.key,
38
+ state,
39
+ ...(bounded(entry.runId, 256) ? { runId: bounded(entry.runId, 256) } : {}),
40
+ ...(previous?.agent ? { agent: previous.agent } : {}),
41
+ ...(previous?.model ? { model: previous.model } : {}),
42
+ ...(previous?.thinking ? { thinking: previous.thinking } : {}),
43
+ });
44
+ }
45
+ for (const step of input.steps ?? []) {
46
+ const key = step.workflowKey;
47
+ if (!key || !KEY_PATTERN.test(key)) continue;
48
+ const state = step.status === "complete" || step.status === "completed" ? "completed"
49
+ : step.status === "failed" ? "failed"
50
+ : step.status === "paused" ? "paused"
51
+ : step.status === "stopped" ? "stopped"
52
+ : step.status === "rejected" ? "rejected"
53
+ : step.status === "pending" ? "pending" : "running";
54
+ const launchResolved = step.async !== undefined || step.sessionFile !== undefined || step.runId !== undefined || step.model !== undefined;
55
+ rows.set(key, {
56
+ childId: key,
57
+ state,
58
+ ...(bounded(step.runId, 256) ? { runId: bounded(step.runId, 256) } : {}),
59
+ ...(launchResolved && bounded(step.agent, 256) ? { agent: bounded(step.agent, 256) } : {}),
60
+ ...(bounded(step.model, 256) ? { model: bounded(step.model, 256) } : {}),
61
+ ...(bounded(step.thinking, 32) ? { thinking: bounded(step.thinking, 32) } : {}),
62
+ });
63
+ }
64
+ for (const child of input.children ?? []) {
65
+ if (!KEY_PATTERN.test(child.key)) continue;
66
+ const result = Array.isArray(child.results) ? child.results.find((value) => value && typeof value === "object") as Record<string, unknown> | undefined : undefined;
67
+ const state = child.detached ? "detached" : child.stopped ? "stopped" : child.interrupted ? "paused" : child.ok ? "completed" : result?.acceptance && typeof result.acceptance === "object" && (result.acceptance as { status?: unknown }).status === "rejected" ? "rejected" : "failed";
68
+ rows.set(child.key, {
69
+ childId: child.key,
70
+ state,
71
+ ...(bounded(child.runId, 256) ? { runId: bounded(child.runId, 256) } : {}),
72
+ ...(bounded(child.agent, 256) ? { agent: bounded(child.agent, 256) } : {}),
73
+ ...(bounded(result?.model, 256) ? { model: bounded(result?.model, 256) } : {}),
74
+ ...(bounded(result?.thinking, 32) ? { thinking: bounded(result?.thinking, 32) } : {}),
75
+ });
76
+ }
77
+ if (input.inventoryComplete) {
78
+ for (const [key, row] of rows) {
79
+ if (!TERMINAL_STATES.has(row.state)) rows.set(key, { ...row, state: input.workflowState === "stopped" ? "stopped" : "failed" });
80
+ }
81
+ }
82
+ return {
83
+ version: 1,
84
+ parentToolCallId: requiredId(input.parentToolCallId, "parentToolCallId"),
85
+ workflowRunId: requiredId(input.workflowRunId, "workflowRunId"),
86
+ inventoryComplete: input.inventoryComplete,
87
+ workflowState: input.workflowState,
88
+ children: [...rows.values()],
89
+ };
90
+ }
91
+
92
+ export function parseWorkflowChildSummary(value: unknown): WorkflowChildSummaryV1 | undefined {
93
+ if (value === undefined) return undefined;
94
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("workflowChildren must be an object.");
95
+ const input = value as Record<string, unknown>;
96
+ const allowed = new Set(["version", "parentToolCallId", "workflowRunId", "inventoryComplete", "workflowState", "children"]);
97
+ if (Object.keys(input).some((key) => !allowed.has(key))) throw new Error("workflowChildren has unsupported fields.");
98
+ if (input.version !== 1 || typeof input.inventoryComplete !== "boolean" || !Array.isArray(input.children)) throw new Error("workflowChildren is invalid.");
99
+ const workflowState = input.workflowState;
100
+ if (workflowState !== "queued" && workflowState !== "running" && workflowState !== "completed" && workflowState !== "failed" && workflowState !== "paused" && workflowState !== "stopped") throw new Error("workflowChildren.workflowState is invalid.");
101
+ const children = input.children.map((row): WorkflowChildSummaryV1["children"][number] => {
102
+ if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error("workflowChildren child row is invalid.");
103
+ const child = row as Record<string, unknown>;
104
+ if (Object.keys(child).some((key) => !["childId", "runId", "agent", "model", "thinking", "state"].includes(key))) throw new Error("workflowChildren child row has unsupported fields.");
105
+ if (typeof child.childId !== "string" || !KEY_PATTERN.test(child.childId)) throw new Error("workflowChildren childId is invalid.");
106
+ const state = child.state;
107
+ if (state !== "pending" && state !== "running" && state !== "completed" && state !== "failed" && state !== "paused" && state !== "stopped" && state !== "rejected" && state !== "detached") throw new Error("workflowChildren child state is invalid.");
108
+ for (const [field, maxBytes] of [["runId", 256], ["agent", 256], ["model", 256], ["thinking", 32]] as const) {
109
+ if (child[field] !== undefined && bounded(child[field], maxBytes) === undefined) throw new Error(`workflowChildren child ${field} is invalid.`);
110
+ }
111
+ return { childId: child.childId, state, ...(child.runId ? { runId: child.runId as string } : {}), ...(child.agent ? { agent: child.agent as string } : {}), ...(child.model ? { model: child.model as string } : {}), ...(child.thinking ? { thinking: child.thinking as string } : {}) };
112
+ });
113
+ if (new Set(children.map((child) => child.childId)).size !== children.length) throw new Error("workflowChildren has duplicate childId values.");
114
+ if (typeof input.parentToolCallId !== "string") throw new Error("workflowChildren.parentToolCallId is invalid.");
115
+ if (typeof input.workflowRunId !== "string") throw new Error("workflowChildren.workflowRunId is invalid.");
116
+ return { version: 1, parentToolCallId: requiredId(input.parentToolCallId, "parentToolCallId"), workflowRunId: requiredId(input.workflowRunId, "workflowRunId"), inventoryComplete: input.inventoryComplete, workflowState, children };
117
+ }