@yycholla/pi-dynamic-workflows 3.3.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 (179) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +325 -0
  3. package/assets/readme/hero.png +0 -0
  4. package/assets/readme/hero.svg +70 -0
  5. package/assets/readme/package-cover.png +0 -0
  6. package/assets/readme/package-cover.svg +66 -0
  7. package/assets/readme/workflow.png +0 -0
  8. package/assets/readme/workflow.svg +77 -0
  9. package/dist/accept-workflow-guidance.d.ts +14 -0
  10. package/dist/accept-workflow-guidance.js +53 -0
  11. package/dist/adversarial-review.d.ts +30 -0
  12. package/dist/adversarial-review.js +107 -0
  13. package/dist/agent-history.d.ts +20 -0
  14. package/dist/agent-history.js +122 -0
  15. package/dist/agent-registry.d.ts +83 -0
  16. package/dist/agent-registry.js +190 -0
  17. package/dist/agent.d.ts +364 -0
  18. package/dist/agent.js +714 -0
  19. package/dist/builtin-commands.d.ts +19 -0
  20. package/dist/builtin-commands.js +251 -0
  21. package/dist/builtin-workflows.d.ts +45 -0
  22. package/dist/builtin-workflows.js +121 -0
  23. package/dist/code-review.d.ts +26 -0
  24. package/dist/code-review.js +181 -0
  25. package/dist/config.d.ts +37 -0
  26. package/dist/config.js +44 -0
  27. package/dist/deep-research.d.ts +30 -0
  28. package/dist/deep-research.js +124 -0
  29. package/dist/display.d.ts +134 -0
  30. package/dist/display.js +248 -0
  31. package/dist/effort-command.d.ts +28 -0
  32. package/dist/effort-command.js +68 -0
  33. package/dist/enums.d.ts +69 -0
  34. package/dist/enums.js +78 -0
  35. package/dist/errors.d.ts +113 -0
  36. package/dist/errors.js +140 -0
  37. package/dist/extension-reload.d.ts +37 -0
  38. package/dist/extension-reload.js +78 -0
  39. package/dist/fs-persistence.d.ts +63 -0
  40. package/dist/fs-persistence.js +102 -0
  41. package/dist/index.d.ts +57 -0
  42. package/dist/index.js +35 -0
  43. package/dist/logger.d.ts +21 -0
  44. package/dist/logger.js +66 -0
  45. package/dist/model-routing.d.ts +30 -0
  46. package/dist/model-routing.js +50 -0
  47. package/dist/model-spec.d.ts +29 -0
  48. package/dist/model-spec.js +252 -0
  49. package/dist/model-tier-config.d.ts +133 -0
  50. package/dist/model-tier-config.js +249 -0
  51. package/dist/run-persistence.d.ts +180 -0
  52. package/dist/run-persistence.js +294 -0
  53. package/dist/saved-commands.d.ts +28 -0
  54. package/dist/saved-commands.js +100 -0
  55. package/dist/shared-store.d.ts +98 -0
  56. package/dist/shared-store.js +212 -0
  57. package/dist/structured-output.d.ts +19 -0
  58. package/dist/structured-output.js +30 -0
  59. package/dist/task-panel.d.ts +61 -0
  60. package/dist/task-panel.js +422 -0
  61. package/dist/usage-limit-scheduler.d.ts +145 -0
  62. package/dist/usage-limit-scheduler.js +368 -0
  63. package/dist/web-tools.d.ts +20 -0
  64. package/dist/web-tools.js +120 -0
  65. package/dist/workflow-authoring-coverage.d.ts +70 -0
  66. package/dist/workflow-authoring-coverage.js +421 -0
  67. package/dist/workflow-authoring-reference.d.ts +20 -0
  68. package/dist/workflow-authoring-reference.js +156 -0
  69. package/dist/workflow-capability-contract.d.ts +131 -0
  70. package/dist/workflow-capability-contract.js +604 -0
  71. package/dist/workflow-commands.d.ts +18 -0
  72. package/dist/workflow-commands.js +260 -0
  73. package/dist/workflow-comprehension.d.ts +133 -0
  74. package/dist/workflow-comprehension.js +1321 -0
  75. package/dist/workflow-context-measurement.d.ts +72 -0
  76. package/dist/workflow-context-measurement.js +213 -0
  77. package/dist/workflow-control-tool.d.ts +30 -0
  78. package/dist/workflow-control-tool.js +176 -0
  79. package/dist/workflow-delivery-choice.d.ts +20 -0
  80. package/dist/workflow-delivery-choice.js +48 -0
  81. package/dist/workflow-editor.d.ts +93 -0
  82. package/dist/workflow-editor.js +363 -0
  83. package/dist/workflow-manager.d.ts +492 -0
  84. package/dist/workflow-manager.js +1124 -0
  85. package/dist/workflow-paths.d.ts +22 -0
  86. package/dist/workflow-paths.js +46 -0
  87. package/dist/workflow-release-gate.d.ts +39 -0
  88. package/dist/workflow-release-gate.js +309 -0
  89. package/dist/workflow-saved.d.ts +38 -0
  90. package/dist/workflow-saved.js +126 -0
  91. package/dist/workflow-settings.d.ts +70 -0
  92. package/dist/workflow-settings.js +131 -0
  93. package/dist/workflow-tool.d.ts +71 -0
  94. package/dist/workflow-tool.js +367 -0
  95. package/dist/workflow-ui.d.ts +182 -0
  96. package/dist/workflow-ui.js +1587 -0
  97. package/dist/workflow.d.ts +333 -0
  98. package/dist/workflow.js +1151 -0
  99. package/dist/workflows-models-command.d.ts +31 -0
  100. package/dist/workflows-models-command.js +156 -0
  101. package/dist/worktree.d.ts +25 -0
  102. package/dist/worktree.js +61 -0
  103. package/extensions/workflow.ts +151 -0
  104. package/package.json +104 -0
  105. package/skills/workflow-authoring/SKILL.md +30 -0
  106. package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
  107. package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
  108. package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
  109. package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
  110. package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
  111. package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
  112. package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
  113. package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
  114. package/skills/workflow-authoring/examples/structured-output.js +37 -0
  115. package/skills/workflow-authoring/examples/tournament.js +81 -0
  116. package/skills/workflow-authoring/examples/validated-gate.js +63 -0
  117. package/skills/workflow-authoring/references/capabilities.md +41 -0
  118. package/skills/workflow-authoring/references/capability-details.md +357 -0
  119. package/skills/workflow-authoring/references/common-helpers.md +4 -0
  120. package/skills/workflow-authoring/references/debugging.md +27 -0
  121. package/skills/workflow-authoring/references/focused-recipes.md +11 -0
  122. package/skills/workflow-authoring/references/helpers.md +9 -0
  123. package/skills/workflow-authoring/references/lifecycle.md +33 -0
  124. package/skills/workflow-authoring/references/pattern-selection.md +15 -0
  125. package/skills/workflow-authoring/references/quality-helpers.md +8 -0
  126. package/skills/workflow-authoring/references/registry-ownership.md +15 -0
  127. package/skills/workflow-authoring/references/retry-helper.md +5 -0
  128. package/skills/workflow-authoring/references/review.md +43 -0
  129. package/skills/workflow-authoring/references/runtime.md +27 -0
  130. package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
  131. package/skills/workflow-authoring/references/versions.md +13 -0
  132. package/skills/workflow-patterns/SKILL.md +51 -0
  133. package/src/accept-workflow-guidance.ts +71 -0
  134. package/src/adversarial-review.ts +120 -0
  135. package/src/agent-history.ts +157 -0
  136. package/src/agent-registry.ts +221 -0
  137. package/src/agent.ts +929 -0
  138. package/src/builtin-commands.ts +286 -0
  139. package/src/builtin-workflows.ts +155 -0
  140. package/src/code-review.ts +183 -0
  141. package/src/config.ts +55 -0
  142. package/src/deep-research.ts +135 -0
  143. package/src/display.ts +367 -0
  144. package/src/effort-command.ts +87 -0
  145. package/src/enums.ts +77 -0
  146. package/src/errors.ts +199 -0
  147. package/src/extension-reload.ts +100 -0
  148. package/src/fs-persistence.ts +124 -0
  149. package/src/index.ts +176 -0
  150. package/src/logger.ts +88 -0
  151. package/src/model-routing.ts +73 -0
  152. package/src/model-spec.ts +309 -0
  153. package/src/model-tier-config.ts +296 -0
  154. package/src/run-persistence.ts +484 -0
  155. package/src/saved-commands.ts +115 -0
  156. package/src/shared-store.ts +228 -0
  157. package/src/structured-output.ts +47 -0
  158. package/src/task-panel.ts +490 -0
  159. package/src/usage-limit-scheduler.ts +432 -0
  160. package/src/web-tools.ts +124 -0
  161. package/src/workflow-authoring-coverage.ts +486 -0
  162. package/src/workflow-authoring-reference.ts +186 -0
  163. package/src/workflow-capability-contract.ts +806 -0
  164. package/src/workflow-commands.ts +287 -0
  165. package/src/workflow-comprehension.ts +1673 -0
  166. package/src/workflow-context-measurement.ts +262 -0
  167. package/src/workflow-control-tool.ts +238 -0
  168. package/src/workflow-delivery-choice.ts +69 -0
  169. package/src/workflow-editor.ts +444 -0
  170. package/src/workflow-manager.ts +1405 -0
  171. package/src/workflow-paths.ts +63 -0
  172. package/src/workflow-release-gate.ts +529 -0
  173. package/src/workflow-saved.ts +180 -0
  174. package/src/workflow-settings.ts +194 -0
  175. package/src/workflow-tool.ts +464 -0
  176. package/src/workflow-ui.ts +1789 -0
  177. package/src/workflow.ts +1615 -0
  178. package/src/workflows-models-command.ts +211 -0
  179. package/src/worktree.ts +76 -0
@@ -0,0 +1,1151 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHash } from "node:crypto";
3
+ import vm from "node:vm";
4
+ import { parse } from "acorn";
5
+ import { WorkflowAgent } from "./agent.js";
6
+ import { agentDefinitionKey, loadAgentRegistry, resolveAgentType, } from "./agent-registry.js";
7
+ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENT_RETRIES, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from "./config.js";
8
+ import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
9
+ import { createWorkflowLogger } from "./logger.js";
10
+ import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
11
+ import { createAgentStoreTools, SharedStore } from "./shared-store.js";
12
+ import { WORKFLOW_CAPABILITY_CONTRACT } from "./workflow-capability-contract.js";
13
+ import { createWorktree, removeWorktree } from "./worktree.js";
14
+ /**
15
+ * Batch-scoped cancellation for a single parallel()/pipeline() fan-out. When a
16
+ * fan-out's agent() calls reserve past maxAgents, the breaching call throws and
17
+ * the whole fan-out rejects — but agents already reserved and queued behind the
18
+ * limiter would otherwise keep draining and spending. parallel()/pipeline()
19
+ * establish a fresh store per call via fanoutScope.run(); agent() captures the
20
+ * nearest enclosing store synchronously (before suspending on the limiter) so a
21
+ * still-queued agent can bail once ITS OWN fan-out breaches, without touching
22
+ * sibling fan-outs running concurrently or an enclosing fan-out when this one is
23
+ * nested inside it (each nesting level gets its own store via ALS scoping).
24
+ *
25
+ * Scope note: cancellation is bounded PER breaching fan-out, not run-global — a
26
+ * deliberate tradeoff. Deep-sixing the earlier run-global flag was required
27
+ * because it wrongly cancelled an innocent, independently-caught sibling batch.
28
+ * The consequence: if one fan-out breaches while an unrelated in-cap sibling or
29
+ * a nested inner fan-out is mid-flight, that other batch is NOT cancelled and
30
+ * finishes its already-reserved agents (still capped at maxAgents total). Only
31
+ * the breaching fan-out's own queue is short-circuited.
32
+ */
33
+ const fanoutScope = new AsyncLocalStorage();
34
+ // Parse-time author hint (fast feedback). The real enforcement is DETERMINISM_PRELUDE.
35
+ const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
36
+ /**
37
+ * Runtime determinism hardening, run inside the vm realm BEFORE the user script.
38
+ * It neuters the nondeterministic builtins that would break resume (they'd make a
39
+ * re-run produce different values than the cached journal):
40
+ * - Math.random() -> throws
41
+ * - Date.now() -> throws
42
+ * - Date() / new Date() -> throws (no-arg); new Date(arg) still works
43
+ * Using the vm realm's own Math/Date/Reflect (not host objects) means this adds
44
+ * no host-`Function` escape. Note: vm is not a security sandbox — an injected
45
+ * bridge function's `.constructor` is still the host Function, so a determined
46
+ * script could bypass this. The guard is best-effort against ACCIDENTAL
47
+ * nondeterminism from trusted (user / guided-LLM) scripts, not a security wall.
48
+ */
49
+ const DETERMINISM_PRELUDE = [
50
+ '"use strict";',
51
+ 'Math.random = () => { throw new Error("Math.random() is unavailable in a workflow (it breaks resume); pass randomness via args or vary by index"); };',
52
+ "{",
53
+ " const RealDate = Date;",
54
+ ' const fail = (w) => { throw new Error(w + " is unavailable in a workflow (it breaks resume); pass a timestamp via args"); };',
55
+ " const SafeDate = function (...a) {",
56
+ ' if (!new.target) fail("Date()");',
57
+ ' if (a.length === 0) fail("new Date()");',
58
+ " return Reflect.construct(RealDate, a, SafeDate);",
59
+ " };",
60
+ " SafeDate.UTC = RealDate.UTC;",
61
+ " SafeDate.parse = RealDate.parse;",
62
+ ' SafeDate.now = () => fail("Date.now()");',
63
+ " SafeDate.prototype = RealDate.prototype;",
64
+ " globalThis.Date = SafeDate;",
65
+ "}",
66
+ ].join("\n");
67
+ export async function runWorkflow(script, options = {}) {
68
+ const started = Date.now();
69
+ const { meta, body } = parseWorkflowScript(script);
70
+ // Per-phase model routing from meta.phases[].model, with meta.model as the default.
71
+ const routingConfig = parseModelRoutingFromMeta(meta.phases, meta.model);
72
+ const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
73
+ const agentTimeoutMs = options.agentTimeoutMs !== undefined ? options.agentTimeoutMs : DEFAULT_AGENT_TIMEOUT_MS;
74
+ const runId = options.runId ?? `run-${started.toString(36)}`;
75
+ const baseCwd = options.cwd ?? process.cwd();
76
+ // Snapshot the agentType registry ONCE per run so two agent() calls can't
77
+ // observe a mid-run edit (determinism); a later resume re-reads it.
78
+ const agentRegistry = options.agentRegistry ?? loadAgentRegistry(baseCwd);
79
+ // Initialize logger
80
+ const logger = createWorkflowLogger({
81
+ runId,
82
+ cwd: options.cwd ?? process.cwd(),
83
+ persist: options.persistLogs ?? true,
84
+ onLog: options.onLog,
85
+ });
86
+ const state = {
87
+ logs: [],
88
+ // When the script declares meta.phases, default the current phase to the
89
+ // first one so agents created before any explicit phase() call still group
90
+ // under a declared phase instead of an orphan "(no phase)" bucket. An
91
+ // explicit phase() (or agent({ phase })) overrides this.
92
+ phases: meta.phases?.[0]?.title ? [meta.phases[0].title] : [],
93
+ currentPhase: meta.phases?.[0]?.title,
94
+ phaseBudgets: new Map(),
95
+ callSeq: 0,
96
+ firstMiss: Number.POSITIVE_INFINITY,
97
+ };
98
+ const agentRunner = options.agent ?? new WorkflowAgent(options);
99
+ const concurrency = normalizeConcurrency(options.concurrency ?? Math.max(1, (globalThis.navigator?.hardwareConcurrency ?? 8) - 2));
100
+ // Global caps + budget are shared with any nested workflow() so they hold across nesting.
101
+ // options.initialTokenUsage (resume() only) seeds spent/tokenUsage so the
102
+ // tokenBudget ceiling holds cumulatively across a pause/resume cycle instead
103
+ // of resetting to zero (see WorkflowRunOptions.initialTokenUsage). Deliberately
104
+ // NOT applied when options.sharedRuntime is supplied — that branch inherits a
105
+ // parent workflow()'s already-live counters, which must not be re-seeded.
106
+ //
107
+ // agentCount is NOT seeded here, unlike spent/tokenUsage — and doesn't need
108
+ // to be: resume() always replays the whole script from callIndex 0, and
109
+ // agent()'s `shared.agentCount++` fires unconditionally for every call
110
+ // (cache-hit replay or live) before the replay-vs-live branch runs. That
111
+ // replay alone reconstructs the correct cumulative count in this fresh
112
+ // SharedRuntime by the time any new live agent executes, so maxAgents stays
113
+ // a genuine cumulative cap across resume with no extra seeding. Token spend
114
+ // needs seeding precisely because its cache-hit branch deliberately does NOT
115
+ // re-run recordTokens() (to avoid double-counting already-spent tokens) —
116
+ // there is no replay-based reconstruction for it the way there is for count.
117
+ const shared = options.sharedRuntime ?? {
118
+ limiter: createLimiter(concurrency),
119
+ agentCount: 0,
120
+ spent: options.initialTokenUsage?.total ?? 0,
121
+ tokenUsage: options.initialTokenUsage
122
+ ? { ...options.initialTokenUsage }
123
+ : { input: 0, output: 0, total: 0, cost: 0, cacheRead: 0, cacheWrite: 0 },
124
+ depth: 0,
125
+ nestedCallSeq: 0,
126
+ runFatalController: new AbortController(),
127
+ inFlight: new Set(),
128
+ };
129
+ const limiter = shared.limiter;
130
+ // This frame created `shared` fresh (rather than inheriting a parent
131
+ // workflow()'s) — i.e. it's the true top-level run, the only frame allowed
132
+ // to declare the run's fate sealed (see SharedRuntime.runFatalController) or
133
+ // drain/dispose the SharedStore. A nested workflow() call always passes both
134
+ // sharedRuntime and sharedStore together (see workflowFn below), so this is
135
+ // equivalent to `!options.sharedStore` — used at both choke points below.
136
+ const isTopLevelRun = !options.sharedRuntime;
137
+ // One store instance per run; nested workflow() calls inherit the parent's store
138
+ // so all agents across nesting levels share the same key-value space.
139
+ const store = options.sharedStore ?? new SharedStore();
140
+ const log = (message) => {
141
+ const text = String(message);
142
+ state.logs.push(text);
143
+ logger.log(text);
144
+ };
145
+ const phase = (title, phaseOptions) => {
146
+ state.currentPhase = title;
147
+ if (!state.phases.includes(title))
148
+ state.phases.push(title);
149
+ // Carve a soft sub-budget from the run total for work done under this phase.
150
+ // Re-declaring re-bases from the current spent (idempotent across resume: the
151
+ // script re-runs phase() and the ceiling is recomputed from live spent).
152
+ if (typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0) {
153
+ state.phaseBudgets.set(title, { budget: phaseOptions.budget, startSpent: shared.spent, warned: false });
154
+ }
155
+ options.onPhase?.(title);
156
+ options.onRuntimeEvent?.({
157
+ type: "phase",
158
+ title,
159
+ budget: typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0 ? phaseOptions.budget : null,
160
+ });
161
+ };
162
+ const budget = Object.freeze({
163
+ total: options.tokenBudget ?? null,
164
+ spent: () => shared.spent,
165
+ remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - shared.spent)),
166
+ });
167
+ const agentLimitError = () => new WorkflowError(`Agent limit exceeded (${maxAgents}). Use maxAgents option to increase the limit.`, WorkflowErrorCode.AGENT_LIMIT_EXCEEDED, { recoverable: false });
168
+ // True on an intentional external abort (pause/stop/Esc, via options.signal)
169
+ // OR once this run's fate has been sealed (shared.runFatalController — see
170
+ // its doc comment). Every abort check in this file goes through this so the
171
+ // two sources compose identically everywhere instead of only some call
172
+ // sites remembering to check the second one.
173
+ const isAborted = () => Boolean(options.signal?.aborted || shared.runFatalController.signal.aborted);
174
+ const throwIfAborted = () => {
175
+ if (isAborted()) {
176
+ throw new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
177
+ }
178
+ };
179
+ const agent = (prompt, agentOptions = {}) => {
180
+ // Track every call (awaited or not) so the top-level run can drain
181
+ // outstanding calls before completing (see SharedRuntime.inFlight and the
182
+ // drain in the finally below) — this is what stops a forgotten `await`
183
+ // from letting an agent mutate state after the run is torn down.
184
+ const call = agentImpl(prompt, agentOptions);
185
+ shared.inFlight.add(call);
186
+ // Attaching a handler here (independent of whatever the script itself does
187
+ // with the returned promise) also means an un-awaited call's eventual
188
+ // rejection never becomes a process-crashing unhandled rejection.
189
+ call.catch(() => { }).finally(() => shared.inFlight.delete(call));
190
+ return call;
191
+ };
192
+ const agentImpl = async (prompt, agentOptions = {}) => {
193
+ throwIfAborted();
194
+ // Capture the enclosing parallel()/pipeline() fan-out's cancellation batch
195
+ // (if any) synchronously, while the ALS context of the caller is still
196
+ // active — i.e. before suspending on the limiter below. The limiter body
197
+ // closes over this so a still-queued agent can bail once its OWN fan-out
198
+ // breaches the cap, without affecting sibling or outer fan-outs.
199
+ const batch = fanoutScope.getStore();
200
+ // Check agent limit. A fan-out that overshoots the cap has already reserved
201
+ // and queued up to `maxAgents` agents; the breaching call throws here, and
202
+ // parallel()/pipeline() mark their own batch cancelled so the already-queued
203
+ // agents short-circuit before their real API call (see the limiter body).
204
+ if (shared.agentCount >= maxAgents) {
205
+ throw agentLimitError();
206
+ }
207
+ if (budget.total !== null && budget.remaining() <= 0) {
208
+ throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
209
+ recoverable: false,
210
+ });
211
+ }
212
+ const assignedPhase = agentOptions.phase ?? state.currentPhase;
213
+ // Per-phase soft sub-budget gate: a noisy phase can exhaust its own ceiling
214
+ // without touching the run's overall budget. Soft (spent accrues post-agent),
215
+ // warns once at ~80%, throws at 100%. Scripts can try/catch around a phase's
216
+ // work so later phases still proceed.
217
+ if (assignedPhase) {
218
+ const pb = state.phaseBudgets.get(assignedPhase);
219
+ if (pb) {
220
+ const phaseSpent = shared.spent - pb.startSpent;
221
+ if (phaseSpent >= pb.budget) {
222
+ throw new WorkflowError(`phase "${assignedPhase}" token sub-budget exhausted (${pb.budget})`, WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, { recoverable: false });
223
+ }
224
+ if (!pb.warned && phaseSpent >= pb.budget * 0.8) {
225
+ pb.warned = true;
226
+ log(`phase "${assignedPhase}" at ${Math.round((phaseSpent / pb.budget) * 100)}% of its token sub-budget`);
227
+ }
228
+ }
229
+ }
230
+ const requestedLabel = agentOptions.label?.trim();
231
+ // Resolve a named agentType to its bound definition (tools/model/prompt).
232
+ const agentDef = resolveAgentType(agentOptions.agentType, agentRegistry);
233
+ if (agentOptions.agentType && !agentDef) {
234
+ log(`unknown agentType "${agentOptions.agentType}"; using default tools/model`);
235
+ }
236
+ // Model precedence: explicit agentOptions.model > agentType.model > tier > phase model.
237
+ // The "explicit-level" model is opts.model, else the definition's model — either
238
+ // beats tier/phase. When only a tier is set, pass undefined here so the tier (not
239
+ // the phase model) decides inside WorkflowAgent.run().
240
+ const explicitModel = agentOptions.model ?? agentDef?.model;
241
+ const modelSpec = explicitModel ?? (agentOptions.tier ? undefined : resolveModelForPhase(assignedPhase, routingConfig));
242
+ // For display in /workflows: the model this agent runs on — its explicit/phase
243
+ // spec, else the session's main model. The real resolved id overrides this via
244
+ // onModelResolved once the subagent session is created.
245
+ let displayModel = modelSpec ?? options.mainModel;
246
+ // Deterministic resume key: assigned at lexical call time, before the limiter,
247
+ // so parallel()/pipeline() fan-out is reproducible for a fixed script.
248
+ const callIndex = state.callSeq++;
249
+ const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions, agentDefinitionKey(agentDef));
250
+ // Store delta key: callIndex alone is NOT run-unique. A nested workflow()
251
+ // call (see workflowFn below) shares this run's SharedStore instance but
252
+ // restarts its own callSeq at 0, so a parent agent and a concurrently
253
+ // running nested-run agent — or two SEQUENTIAL sibling nested runs, whose
254
+ // depth alone would otherwise repeat — can both get callIndex 0 and
255
+ // collide in SharedStore.agentDeltas — whichever commits last
256
+ // steals/overwrites the other's journaled delta (and, via this same
257
+ // deltaKey doubling as the onAgentStart/onAgentEnd/onAgentHistory event
258
+ // id, misattributes one agent's events to the other — see item 2's
259
+ // identity model). Composing the run's own runId (unique per top-level
260
+ // run AND per nested run, see `${runId}-nested${++shared.nestedCallSeq}`
261
+ // below) with callIndex makes the key unique across the whole store.
262
+ const deltaKey = `${runId}:${callIndex}`;
263
+ // Reserve the agent slot synchronously — atomic with the limit/budget gate
264
+ // above (no await in between) — so a parallel() fan-out can't all observe the
265
+ // same agentCount and overshoot maxAgents. (Token budget stays a soft gate:
266
+ // spent accrues after each agent, matching Claude Code; in-flight agents may
267
+ // push slightly past total, then further agent() calls throw.)
268
+ shared.agentCount++;
269
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
270
+ // Longest-unchanged-prefix resume: replay a cached result only while the
271
+ // prefix is still intact — this call's index is before the first changed/new
272
+ // call. Once any call misses, it AND everything after it run live (matching
273
+ // Claude Code's contract), so an edited upstream call never leaves stale
274
+ // downstream results served from the journal.
275
+ // Namespaced the same way as SharedStore's deltaKey (deltaKey IS this
276
+ // exact `${runId}:${callIndex}` string) so a nested workflow()'s
277
+ // callIndex-0 can never accidentally replay the parent's callIndex-0
278
+ // entry, or vice versa (see JournalEntry.runId).
279
+ const cached = options.resumeJournal?.get(deltaKey);
280
+ const hashMatches = cached != null && cached.hash === callHash;
281
+ const cachedEmptyOutput = hashMatches && isEmptyTextAgentResult(cached.result, agentOptions.schema);
282
+ if (hashMatches && !cachedEmptyOutput && callIndex < state.firstMiss) {
283
+ options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt, model: displayModel });
284
+ options.onAgentEnd?.({
285
+ id: deltaKey,
286
+ label,
287
+ phase: assignedPhase,
288
+ result: cached.result,
289
+ tokens: 0,
290
+ model: displayModel,
291
+ });
292
+ // Apply this agent's write delta so live agents later in the run see a
293
+ // consistent store. Additive apply preserves parallel-agent writes that
294
+ // came from higher-callIndex agents finishing before this one.
295
+ if (cached.storeDelta)
296
+ store.applyDelta(cached.storeDelta);
297
+ return cached.result;
298
+ }
299
+ // A genuine miss (no journal entry, or the hash changed) marks where the
300
+ // unchanged prefix ends; this call and every later one then run live.
301
+ if (!hashMatches || cachedEmptyOutput)
302
+ state.firstMiss = Math.min(state.firstMiss, callIndex);
303
+ return limiter(async () => {
304
+ const timeout = agentOptions.timeoutMs !== undefined ? agentOptions.timeoutMs : agentTimeoutMs;
305
+ const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
306
+ const maxAttempts = retryAttempts + 1;
307
+ options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt, model: displayModel });
308
+ // Optional per-agent worktree isolation (deterministic name -> stable resume keys).
309
+ // Precedence: explicit call-site isolation > agentDef isolation.
310
+ // Note: passing { isolation: undefined } falls through ?? to the def's value — there
311
+ // is no sentinel to suppress a def's isolation at the call site. Remove the agentType
312
+ // or override with a def that has no isolation field if opt-out is needed.
313
+ let worktree;
314
+ const resolvedIsolation = agentOptions.isolation ?? agentDef?.isolation;
315
+ if (resolvedIsolation === "worktree") {
316
+ worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
317
+ if (!worktree.isolated)
318
+ log(`isolation ignored for "${label}" (${worktree.reason})`);
319
+ }
320
+ const runCwd = worktree?.isolated ? worktree.cwd : undefined;
321
+ // Captured from the subagent's real session usage; falls back to an
322
+ // estimate when the provider reports no usage (total === 0). Usage is reset
323
+ // per retry attempt so a failed attempt does not double-count the next one.
324
+ let usage;
325
+ const recordTokens = (result) => {
326
+ const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(prompt);
327
+ if (usage) {
328
+ shared.tokenUsage.input += usage.input;
329
+ shared.tokenUsage.output += usage.output;
330
+ shared.tokenUsage.cost += usage.cost;
331
+ shared.tokenUsage.cacheRead += usage.cacheRead;
332
+ shared.tokenUsage.cacheWrite += usage.cacheWrite;
333
+ }
334
+ shared.tokenUsage.total += tokens;
335
+ shared.spent += tokens;
336
+ return tokens;
337
+ };
338
+ try {
339
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
340
+ usage = undefined;
341
+ const externalSignal = options.signal;
342
+ let onExternalAbort;
343
+ let onRunFatal;
344
+ try {
345
+ throwIfAborted();
346
+ // This agent's own fan-out already breached maxAgents while this
347
+ // call sat queued behind the limiter; bail before spending on the
348
+ // real API call instead of draining the whole reserved queue.
349
+ if (batch?.cancelled)
350
+ throw agentLimitError();
351
+ // Per-attempt abort: on timeout we abort THIS agent so its session is
352
+ // disposed and its heavy state (messages, etc.) released, instead of
353
+ // leaving it streaming in the background — retries would otherwise
354
+ // stack live sessions on top of each other (#109). Linked to BOTH the
355
+ // run's external signal (outer abort — pause/stop/Esc) AND
356
+ // shared.runFatalController (this run's fate has been sealed by a
357
+ // sibling's non-recoverable error escaping the top-level script — see
358
+ // SharedRuntime.runFatalController) so an in-flight sibling actually
359
+ // winds down instead of running to completion on a doomed run. Both
360
+ // links are torn down per attempt in finally so listeners don't accrue.
361
+ const agentController = new AbortController();
362
+ if (isAborted()) {
363
+ agentController.abort();
364
+ }
365
+ else {
366
+ if (externalSignal) {
367
+ onExternalAbort = () => agentController.abort();
368
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
369
+ }
370
+ onRunFatal = () => agentController.abort();
371
+ shared.runFatalController.signal.addEventListener("abort", onRunFatal, { once: true });
372
+ }
373
+ const runPromise = agentRunner.run(prompt, {
374
+ label,
375
+ // Identifiable name for persisted sessions (persistAgentSessions).
376
+ sessionName: `workflow:${runId} ${label}`,
377
+ schema: agentOptions.schema,
378
+ signal: agentController.signal,
379
+ instructions: buildAgentInstructions(assignedPhase, agentOptions, agentDef, resolvedIsolation),
380
+ model: modelSpec,
381
+ tier: agentOptions.tier,
382
+ modelRegistry: options.modelRegistry,
383
+ toolNames: agentDef?.tools,
384
+ disallowedToolNames: agentDef?.disallowedTools,
385
+ // Per-agent store tools track this agent's writes by the
386
+ // run-unique deltaKey so the delta can be journaled and replayed
387
+ // correctly on resume, even when a nested workflow() run shares
388
+ // this store concurrently with the parent run.
389
+ systemTools: createAgentStoreTools(store, deltaKey),
390
+ cwd: runCwd,
391
+ onModelResolved: (id) => {
392
+ displayModel = id;
393
+ },
394
+ onModelFallback: (spec) => {
395
+ // Make the silent degrade visible in /workflows, not just console.
396
+ log(`${label}: model "${spec}" unavailable — using the session default`);
397
+ },
398
+ onUsage: (u) => {
399
+ usage = u;
400
+ },
401
+ onHistory: (history) => {
402
+ options.onAgentHistory?.({ id: deltaKey, label, phase: assignedPhase, history });
403
+ },
404
+ });
405
+ // After a timeout the run() promise still settles later, rejecting with
406
+ // "aborted" once agentController fires; the race has already resolved,
407
+ // so swallow that to avoid an unhandled rejection.
408
+ runPromise.catch(() => { });
409
+ const result = await withTimeout(runPromise, timeout, label, () => agentController.abort());
410
+ throwIfAborted();
411
+ if (isEmptyTextAgentResult(result, agentOptions.schema)) {
412
+ throw new WorkflowError("Subagent produced no assistant output", WorkflowErrorCode.AGENT_EMPTY_OUTPUT, {
413
+ recoverable: true,
414
+ agentLabel: label,
415
+ });
416
+ }
417
+ const tokens = recordTokens(result);
418
+ options.onAgentJournal?.({
419
+ index: callIndex,
420
+ runId,
421
+ hash: callHash,
422
+ result,
423
+ storeDelta: store.commitDelta(deltaKey),
424
+ });
425
+ options.onAgentEnd?.({
426
+ id: deltaKey,
427
+ label,
428
+ phase: assignedPhase,
429
+ result,
430
+ tokens,
431
+ tokenUsage: usage,
432
+ worktree: runCwd,
433
+ model: displayModel,
434
+ });
435
+ return result;
436
+ }
437
+ catch (error) {
438
+ if (isAborted())
439
+ throw error;
440
+ const workflowError = wrapError(error, { agentLabel: label });
441
+ logger.error(`agent ${label} attempt ${attempt}/${maxAttempts} failed: ${workflowError.message}`);
442
+ const tokens = recordTokens(null);
443
+ // This attempt's store writes must not survive it — a failed
444
+ // attempt shares this call's deltaKey with every other attempt
445
+ // (retried or not), so without rolling back here its writes would
446
+ // stay live in the store (visible to concurrently-running sibling
447
+ // agents) and merge into whatever a later, successful attempt
448
+ // commits — corrupting both the live run's state and the delta
449
+ // that resume replay reconstructs from. Unconditional: this
450
+ // covers the about-to-retry case AND the exhausted/non-recoverable
451
+ // case, since neither leaves behind a call that "produced" a
452
+ // result this attempt's writes should be attributed to.
453
+ store.discardDelta(deltaKey);
454
+ if (workflowError.recoverable && attempt < maxAttempts) {
455
+ log(`agent "${label}" attempt ${attempt}/${maxAttempts} failed: ${workflowError.code} ${workflowError.message}; retrying`);
456
+ // This attempt's spend already accrued into shared.spent/tokenUsage
457
+ // above (recordTokens) — but it will never reach onAgentEnd (only
458
+ // the final attempt does), so report it on the dedicated channel
459
+ // instead (see WorkflowRunOptions.onRetrySpend).
460
+ options.onRetrySpend?.(tokens);
461
+ continue;
462
+ }
463
+ options.onAgentEnd?.({
464
+ id: deltaKey,
465
+ label,
466
+ phase: assignedPhase,
467
+ result: null,
468
+ tokens,
469
+ tokenUsage: usage,
470
+ worktree: runCwd,
471
+ model: displayModel,
472
+ error: workflowError.message,
473
+ errorCode: workflowError.code,
474
+ recoverable: workflowError.recoverable,
475
+ });
476
+ if (workflowError.recoverable) {
477
+ log(`agent "${label}" exhausted ${maxAttempts} attempt${maxAttempts === 1 ? "" : "s"}: ${workflowError.code} ${workflowError.message}`);
478
+ return null;
479
+ }
480
+ throw workflowError;
481
+ }
482
+ finally {
483
+ // Drop this attempt's abort listeners so they don't accrue one entry
484
+ // per attempt on the run's signal / runFatalController for the whole
485
+ // run (#109 hygiene).
486
+ if (onExternalAbort)
487
+ externalSignal?.removeEventListener("abort", onExternalAbort);
488
+ if (onRunFatal)
489
+ shared.runFatalController.signal.removeEventListener("abort", onRunFatal);
490
+ }
491
+ }
492
+ return null;
493
+ }
494
+ finally {
495
+ // Always tear down the worktree, even on timeout/abort.
496
+ if (worktree?.isolated)
497
+ await removeWorktree(worktree);
498
+ }
499
+ });
500
+ };
501
+ const parallel = async (thunks) => {
502
+ throwIfAborted();
503
+ if (!Array.isArray(thunks))
504
+ throw new TypeError("parallel() expects an array of functions");
505
+ if (thunks.some((thunk) => typeof thunk !== "function")) {
506
+ throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
507
+ }
508
+ // Batch-scoped cancellation: agent() calls made (directly or transitively)
509
+ // from these thunks see this store via fanoutScope.getStore(). A breach in
510
+ // THIS fan-out flips `cancelled` so its own still-queued agents bail, without
511
+ // touching a sibling fan-out running concurrently or an enclosing one.
512
+ const batch = { cancelled: false };
513
+ return fanoutScope.run(batch, () => Promise.all(thunks.map(async (thunk, index) => {
514
+ try {
515
+ return await thunk();
516
+ }
517
+ catch (error) {
518
+ if (isAborted())
519
+ throw error;
520
+ const workflowError = wrapError(error);
521
+ // Non-recoverable failures (token budget / agent limit exhausted) must
522
+ // halt the whole run, exactly like a directly-awaited agent() — not be
523
+ // swallowed into a null in the result array.
524
+ if (!workflowError.recoverable) {
525
+ // Only a breached agent cap cancels the rest of this batch; the
526
+ // token budget stays a soft gate by design (in-flight agents may
527
+ // finish past it), and other non-recoverable errors don't imply
528
+ // the rest of the batch is doomed.
529
+ if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED)
530
+ batch.cancelled = true;
531
+ throw workflowError;
532
+ }
533
+ log(`parallel[${index}] failed: ${workflowError.message}`);
534
+ return null;
535
+ }
536
+ })));
537
+ };
538
+ const pipeline = async (items, ...stages) => {
539
+ throwIfAborted();
540
+ if (!Array.isArray(items))
541
+ throw new TypeError("pipeline() expects an array as the first argument");
542
+ if (stages.some((stage) => typeof stage !== "function")) {
543
+ throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
544
+ }
545
+ // Batch-scoped cancellation — see parallel() for the rationale.
546
+ const batch = { cancelled: false };
547
+ return fanoutScope.run(batch, () => Promise.all(items.map(async (item, index) => {
548
+ let value = item;
549
+ for (const stage of stages) {
550
+ try {
551
+ throwIfAborted();
552
+ value = await stage(value, item, index);
553
+ throwIfAborted();
554
+ }
555
+ catch (error) {
556
+ if (isAborted())
557
+ throw error;
558
+ const workflowError = wrapError(error);
559
+ // Non-recoverable failures halt the whole run (see parallel()).
560
+ if (!workflowError.recoverable) {
561
+ if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED)
562
+ batch.cancelled = true;
563
+ throw workflowError;
564
+ }
565
+ log(`pipeline[${index}] failed: ${workflowError.message}`);
566
+ return null;
567
+ }
568
+ }
569
+ return value;
570
+ })));
571
+ };
572
+ // Nested workflow(): run a saved workflow (or a raw script) inline, sharing this
573
+ // run's limiter/counters/budget so the global caps hold. One level deep only.
574
+ const workflowFn = async (nameOrScript, childArgs) => {
575
+ throwIfAborted();
576
+ if (shared.depth >= 1) {
577
+ throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
578
+ recoverable: false,
579
+ });
580
+ }
581
+ const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
582
+ const childScript = resolved ?? String(nameOrScript);
583
+ const workflowName = String(nameOrScript);
584
+ options.onRuntimeEvent?.({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
585
+ shared.depth++;
586
+ try {
587
+ // Propagate the resumeJournal into the child frame ONLY while the
588
+ // parent's own longest-unchanged-prefix is still intact at the moment
589
+ // of this workflow() call (state.firstMiss === Infinity, i.e. every
590
+ // parent agent()/checkpoint() call BEFORE this one was a cache hit).
591
+ // This is namespacing-safe (see JournalEntry.runId) but namespacing
592
+ // alone is NOT sufficient: SharedStore content itself is not part of
593
+ // any call's hash, so a cached child result was computed against
594
+ // whatever store state the UPSTREAM parent calls had written at the
595
+ // time it originally ran live. If an upstream parent call misses
596
+ // (edited script) and re-runs live, it may write different store
597
+ // values than it did originally — a child cached under the OLD store
598
+ // state would then be replaying a result that's stale with respect to
599
+ // the NEW live state, even though the child's own hash still matches.
600
+ // The prefix contract already treats "this call sits after a miss" as
601
+ // "must run live" for calls within one frame; a nested workflow() is
602
+ // no exception; once anything upstream in the parent has missed, cut
603
+ // the child off from the journal entirely so it runs fully live.
604
+ const prefixIntact = state.firstMiss === Number.POSITIVE_INFINITY;
605
+ const child = await runWorkflow(childScript, {
606
+ ...options,
607
+ args: childArgs,
608
+ sharedRuntime: shared,
609
+ // Propagate the parent's store so nested agents share the same key-value space.
610
+ sharedStore: store,
611
+ resumeJournal: prefixIntact ? options.resumeJournal : undefined,
612
+ resumeFromRunId: undefined,
613
+ // shared.nestedCallSeq, not shared.depth — see its doc comment: depth
614
+ // returns to 0 between sequential sibling calls, which would otherwise
615
+ // mint the same child runId (and hence colliding deltaKeys/event ids)
616
+ // for two different children.
617
+ runId: `${runId}-nested${++shared.nestedCallSeq}`,
618
+ persistLogs: false,
619
+ });
620
+ return child.result;
621
+ }
622
+ finally {
623
+ shared.depth--;
624
+ options.onRuntimeEvent?.({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
625
+ }
626
+ };
627
+ // ── Quality-pattern stdlib: reusable, deterministic helpers built purely on
628
+ // agent()/parallel() (so callSeq ordering stays stable and resume keeps working).
629
+ // Injected as globals so workflow scripts compose them directly. ──
630
+ const VERIFY_SCHEMA = {
631
+ type: "object",
632
+ properties: { real: { type: "boolean" }, reason: { type: "string" } },
633
+ required: ["real"],
634
+ };
635
+ const verify = async (item, opts = {}) => {
636
+ options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "verify" });
637
+ const reviewers = Math.max(1, opts.reviewers ?? 2);
638
+ const threshold = opts.threshold ?? 0.5;
639
+ const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
640
+ const claim = typeof item === "string" ? item : JSON.stringify(item);
641
+ const votes = (await parallel(Array.from({ length: reviewers }, (_v, i) => () => agent(`Adversarially review whether the following is REAL/correct. Try to refute it; default to real=false if unsure.${lenses.length ? ` Focus lens: ${lenses[i % lenses.length]}.` : ""}\n\n${claim}`, { label: `verify ${i + 1}`, schema: VERIFY_SCHEMA })))).filter(Boolean);
642
+ const realCount = votes.filter((v) => v?.real).length;
643
+ const verdict = {
644
+ real: votes.length > 0 && realCount / votes.length >= threshold,
645
+ realCount,
646
+ total: votes.length,
647
+ votes,
648
+ };
649
+ options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "verify" });
650
+ return verdict;
651
+ };
652
+ const JUDGE_SCHEMA = {
653
+ type: "object",
654
+ properties: { score: { type: "number" }, reason: { type: "string" } },
655
+ required: ["score"],
656
+ };
657
+ const judgePanel = async (attempts, opts = {}) => {
658
+ options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "judgePanel" });
659
+ const judges = Math.max(1, opts.judges ?? 3);
660
+ const rubric = opts.rubric ?? "overall quality and correctness";
661
+ const scored = (await parallel((Array.isArray(attempts) ? attempts : []).map((att, idx) => async () => {
662
+ const text = typeof att === "string" ? att : JSON.stringify(att);
663
+ const js = (await parallel(Array.from({ length: judges }, (_v, j) => () => agent(`Score this candidate from 0 to 1 on: ${rubric}. Reply with the score.\n\nCandidate:\n${text}`, {
664
+ label: `judge ${idx + 1}.${j + 1}`,
665
+ schema: JUDGE_SCHEMA,
666
+ })))).filter(Boolean);
667
+ const score = js.length ? js.reduce((s, v) => s + (Number(v?.score) || 0), 0) / js.length : 0;
668
+ return { index: idx, attempt: att, score, judgments: js };
669
+ }))).filter(Boolean);
670
+ // Highest mean score; stable tie-break by input index.
671
+ let best = scored[0];
672
+ for (const s of scored)
673
+ if (s.score > best.score || (s.score === best.score && s.index < best.index))
674
+ best = s;
675
+ options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
676
+ return best;
677
+ };
678
+ const loopUntilDry = async (opts) => {
679
+ if (!opts || typeof opts.round !== "function")
680
+ throw new TypeError("loopUntilDry requires { round: (i) => items[] }");
681
+ const key = opts.key ?? ((x) => JSON.stringify(x));
682
+ const consecutiveEmpty = Math.max(1, opts.consecutiveEmpty ?? 2);
683
+ const maxRounds = opts.maxRounds ?? 50;
684
+ const seen = new Set();
685
+ const all = [];
686
+ let dry = 0;
687
+ for (let r = 0; r < maxRounds && dry < consecutiveEmpty; r++) {
688
+ let items;
689
+ try {
690
+ items = (await opts.round(r)) ?? [];
691
+ }
692
+ catch (error) {
693
+ // Budget / agent-limit exhaustion: return the partial result, don't abort.
694
+ const code = error?.code;
695
+ if (code === WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED || code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED)
696
+ break;
697
+ throw error;
698
+ }
699
+ const fresh = (Array.isArray(items) ? items : []).filter((x) => x != null && !seen.has(key(x)));
700
+ if (!fresh.length) {
701
+ dry++;
702
+ continue;
703
+ }
704
+ dry = 0;
705
+ for (const x of fresh) {
706
+ seen.add(key(x));
707
+ all.push(x);
708
+ }
709
+ }
710
+ return all;
711
+ };
712
+ const COMPLETENESS_SCHEMA = {
713
+ type: "object",
714
+ properties: { complete: { type: "boolean" }, missing: { type: "array", items: { type: "string" } } },
715
+ required: ["complete"],
716
+ };
717
+ const completenessCheck = async (taskArgs, results) => {
718
+ options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "completenessCheck" });
719
+ const verdict = await agent(`Given the task and the results gathered so far, list what is still MISSING (modalities not covered, claims unverified, gaps). Be specific and concise.\n\nTask:\n${JSON.stringify(taskArgs)}\n\nResults so far:\n${JSON.stringify(results).slice(0, 4000)}`, { label: "completeness critic", schema: COMPLETENESS_SCHEMA });
720
+ options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "completenessCheck" });
721
+ return verdict;
722
+ };
723
+ // Thin bounded-retry / validation-gate combinators. Sugar over the for-loop +
724
+ // agent() pattern, but each attempt is a real agent() call so it auto-journals
725
+ // under a stable callSeq (resume-safe). No backoff: there is no timer in the vm
726
+ // and a delay has no resume value. NOTE: attempt N+1's call hash depends on N's
727
+ // live result, so a retry/gate chain cache-miss-cascades on resume (correct).
728
+ const retry = async (thunk, opts = {}) => {
729
+ const attempts = Math.max(1, opts.attempts ?? 3);
730
+ let last;
731
+ for (let i = 0; i < attempts; i++) {
732
+ last = await thunk(i);
733
+ const accepted = !opts.until || opts.until(last);
734
+ options.onRuntimeEvent?.({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
735
+ if (accepted)
736
+ return last;
737
+ }
738
+ return last; // attempts exhausted — return the last result (caller inspects it)
739
+ };
740
+ const gate = async (thunk, validator, opts = {}) => {
741
+ const attempts = Math.max(1, opts.attempts ?? 3);
742
+ let feedback;
743
+ let last;
744
+ for (let i = 0; i < attempts; i++) {
745
+ last = await thunk(feedback, i);
746
+ const verdict = await validator(last);
747
+ const accepted = Boolean(verdict?.ok);
748
+ options.onRuntimeEvent?.({ type: "control-attempt", helper: "gate", attempt: i + 1, accepted });
749
+ if (accepted)
750
+ return { ok: true, value: last, attempts: i + 1 };
751
+ feedback = verdict?.feedback; // fed into the next attempt
752
+ }
753
+ return { ok: false, value: last, attempts };
754
+ };
755
+ // Deterministic, journaled, replayable human checkpoint. Spends no tokens, so it
756
+ // is gated on the agent counter + abort (not budget). On resume the human's reply
757
+ // replays by callIndex exactly like a cached agent() — the genuine edge over CC,
758
+ // whose steering is in-session only. Headless (no UI threaded in): takes the
759
+ // declared default and journals THAT, so a detached/background run never hangs.
760
+ const checkpoint = async (promptText, checkpointOptions = {}) => {
761
+ throwIfAborted();
762
+ if (typeof promptText !== "string")
763
+ throw new TypeError("checkpoint(promptText, options?) needs a prompt string");
764
+ if (shared.agentCount >= maxAgents) {
765
+ throw agentLimitError();
766
+ }
767
+ const callIndex = state.callSeq++;
768
+ const callHash = hashCheckpoint(promptText, checkpointOptions);
769
+ // Namespaced by runId like agent()'s deltaKey — see JournalEntry.runId.
770
+ const journalKey = `${runId}:${callIndex}`;
771
+ const cached = options.resumeJournal?.get(journalKey);
772
+ if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
773
+ shared.agentCount++;
774
+ return cached.result; // replay the journaled human reply
775
+ }
776
+ if (cached == null || cached.hash !== callHash)
777
+ state.firstMiss = Math.min(state.firstMiss, callIndex);
778
+ shared.agentCount++;
779
+ let reply;
780
+ if (options.confirm) {
781
+ reply = await options.confirm(promptText, checkpointOptions);
782
+ }
783
+ else if (checkpointOptions.headless === "abort") {
784
+ throw new WorkflowError(`checkpoint "${promptText}" needs human input but none is available (headless run)`, WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: false });
785
+ }
786
+ else {
787
+ reply = checkpointOptions.default ?? true;
788
+ }
789
+ throwIfAborted();
790
+ options.onAgentJournal?.({ index: callIndex, runId, hash: callHash, result: reply });
791
+ return reply;
792
+ };
793
+ const runtimeImplementations = {
794
+ agent,
795
+ parallel,
796
+ pipeline,
797
+ workflow: workflowFn,
798
+ verify,
799
+ judgePanel,
800
+ loopUntilDry,
801
+ completenessCheck,
802
+ retry,
803
+ gate,
804
+ checkpoint,
805
+ log,
806
+ phase,
807
+ args: options.args,
808
+ cwd: options.cwd ?? process.cwd(),
809
+ process: Object.freeze({ cwd: () => options.cwd ?? process.cwd() }),
810
+ budget,
811
+ console: {
812
+ log,
813
+ info: log,
814
+ warn: (m) => log(`[warn] ${String(m)}`),
815
+ error: (m) => log(`[error] ${String(m)}`),
816
+ },
817
+ };
818
+ const { globals: projectGlobals, diagnostics: bindingDiagnostics } = WORKFLOW_CAPABILITY_CONTRACT.assembleRuntimeBindings(runtimeImplementations);
819
+ for (const diagnostic of bindingDiagnostics)
820
+ logger.warn(diagnostic.message);
821
+ const context = vm.createContext({
822
+ ...projectGlobals,
823
+ // Object/Array/JSON/Math/Date/Promise/Set/Map/etc. come from the vm realm
824
+ // itself — we deliberately do NOT inject host built-ins, whose .constructor
825
+ // would be the host Function (a determinism-guard bypass). Math/Date are
826
+ // neutered in-realm by DETERMINISM_PRELUDE below.
827
+ });
828
+ const wrapped = `${DETERMINISM_PRELUDE}\n(async () => {\n${body}\n})()`;
829
+ try {
830
+ const result = await new vm.Script(wrapped, { filename: `${meta.name || "workflow"}.js` }).runInContext(context);
831
+ // Persist logs
832
+ const logFile = logger.persist();
833
+ if (logFile) {
834
+ log(`Logs persisted to ${logFile}`);
835
+ }
836
+ // Emit final token usage
837
+ options.onTokenUsage?.(shared.tokenUsage);
838
+ return {
839
+ meta,
840
+ result: result,
841
+ logs: state.logs,
842
+ phases: state.phases,
843
+ agentCount: shared.agentCount,
844
+ durationMs: Date.now() - started,
845
+ runId,
846
+ tokenUsage: shared.tokenUsage,
847
+ };
848
+ }
849
+ catch (error) {
850
+ // This error just escaped THIS frame's own vm script execution completely
851
+ // uncaught. For the top-level frame that means nothing anywhere in the
852
+ // whole call chain (this script, any enclosing try/catch around a nested
853
+ // workflow()/parallel()/agent()) caught it — the run's fate is genuinely
854
+ // sealed now (see SharedRuntime.runFatalController). Sealing it here, not
855
+ // inside agent()/parallel(), is what preserves parallel()'s "a thrown
856
+ // thunk resolves to null without failing the others" contract and a
857
+ // script's own try/catch around agent()/workflow(): both those cases are
858
+ // swallowed well before an error would ever reach this catch. A NESTED
859
+ // frame reaching here does NOT seal anything — the parent script may still
860
+ // catch workflow()'s rejection and continue, so only isTopLevelRun acts.
861
+ // Idempotent: if this is already an intentional pause/stop (options.signal
862
+ // aborted) or a second escape after the fatal signal already fired,
863
+ // aborting an already-aborted controller is a no-op.
864
+ //
865
+ // This also fires on a PROVIDER_USAGE_LIMIT escape (a quota/rate-limit
866
+ // hit), not just a genuine bug — that error is non-recoverable too (see
867
+ // errors.ts), so it escapes exactly like any other run-fatal error and
868
+ // seals the same way. Deliberate tradeoff: any sibling still in flight
869
+ // when the quota was hit gets aborted rather than allowed to finish and
870
+ // journal — this stops burning an already-exhausted budget right now, at
871
+ // the cost of that sibling's work being thrown away and re-run live when
872
+ // the paused run resumes (it was never journaled, so it isn't cached).
873
+ if (isTopLevelRun)
874
+ shared.runFatalController.abort();
875
+ throw error;
876
+ }
877
+ finally {
878
+ // Only the top-level frame drains/disposes (see isTopLevelRun) — a nested
879
+ // workflow()'s in-flight agents are still tracked in this SAME shared set
880
+ // and get drained once, here, when the whole run finishes.
881
+ if (isTopLevelRun) {
882
+ // Wait out every agent() call spawned anywhere in this run — including
883
+ // ones the script never awaited — before the store goes away. Without
884
+ // this, a forgotten `await agent(...)` could keep mutating store/journal
885
+ // state after the run is marked complete/failed and torn down. Loop
886
+ // (not a single Promise.allSettled) because draining can itself let a
887
+ // still-running call schedule further work that adds to the set.
888
+ //
889
+ // Caveat: this can block indefinitely. A run-fatal abort (see the catch
890
+ // above) aborts the AbortSignal passed to each in-flight agent, but that
891
+ // is cooperative — an agent runner that ignores its signal (or one still
892
+ // waiting out a real subagent process that won't die) never settles on
893
+ // its own. Combined with agentTimeoutMs: null (no hard timeout, the
894
+ // default), a single hung, signal-ignoring, un-awaited agent() call can
895
+ // wedge this drain — and therefore the whole run's completion — forever.
896
+ // Configure a finite agentTimeoutMs (run- or per-agent-level) for any
897
+ // workflow where this is a real risk; there is no drain-side timeout.
898
+ if (shared.inFlight.size > 0) {
899
+ log(`waiting for ${shared.inFlight.size} outstanding agent() call(s) to settle before this run completes`);
900
+ }
901
+ while (shared.inFlight.size > 0) {
902
+ await Promise.allSettled(Array.from(shared.inFlight));
903
+ }
904
+ store.dispose();
905
+ }
906
+ }
907
+ }
908
+ export function parseWorkflowScript(script) {
909
+ if (DETERMINISM_BLOCKLIST.test(script)) {
910
+ throw new WorkflowError("Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false });
911
+ }
912
+ const ast = parse(script, {
913
+ ecmaVersion: "latest",
914
+ sourceType: "module",
915
+ allowAwaitOutsideFunction: true,
916
+ allowReturnOutsideFunction: true,
917
+ ranges: false,
918
+ });
919
+ const first = ast.body?.[0];
920
+ if (first?.type !== "ExportNamedDeclaration") {
921
+ throw new WorkflowError("`export const meta = { name, description, phases }` must be the first statement in the script", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false });
922
+ }
923
+ const declaration = first.declaration;
924
+ if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") {
925
+ throw new WorkflowError("meta export must be `export const meta = ...`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
926
+ recoverable: false,
927
+ });
928
+ }
929
+ if (declaration.declarations.length !== 1) {
930
+ throw new WorkflowError("meta export must declare only `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
931
+ recoverable: false,
932
+ });
933
+ }
934
+ const declarator = declaration.declarations[0];
935
+ if (declarator.id?.type !== "Identifier" || declarator.id.name !== "meta") {
936
+ throw new WorkflowError("meta export must declare `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
937
+ recoverable: false,
938
+ });
939
+ }
940
+ if (!declarator.init)
941
+ throw new WorkflowError("meta must have a literal value", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
942
+ recoverable: false,
943
+ });
944
+ const meta = evaluateLiteral(declarator.init, "meta");
945
+ validateMeta(meta);
946
+ return {
947
+ meta,
948
+ body: script.slice(0, first.start) + script.slice(first.end),
949
+ };
950
+ }
951
+ function evaluateLiteral(node, path) {
952
+ switch (node.type) {
953
+ case "ObjectExpression": {
954
+ const out = {};
955
+ for (const prop of node.properties) {
956
+ if (prop.type === "SpreadElement")
957
+ throw new Error(`spread not allowed in ${path}`);
958
+ if (prop.type !== "Property")
959
+ throw new Error(`only plain properties allowed in ${path}`);
960
+ if (prop.computed)
961
+ throw new Error(`computed keys not allowed in ${path}`);
962
+ if (prop.kind !== "init" || prop.method)
963
+ throw new Error(`methods/accessors not allowed in ${path}`);
964
+ const key = propertyKey(prop.key, path);
965
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
966
+ throw new Error(`reserved key name not allowed in ${path}: ${key}`);
967
+ }
968
+ out[key] = evaluateLiteral(prop.value, `${path}.${key}`);
969
+ }
970
+ return out;
971
+ }
972
+ case "ArrayExpression":
973
+ return node.elements.map((element, index) => {
974
+ if (!element)
975
+ throw new Error(`sparse arrays not allowed in ${path}`);
976
+ if (element.type === "SpreadElement")
977
+ throw new Error(`spread not allowed in ${path}`);
978
+ return evaluateLiteral(element, `${path}[${index}]`);
979
+ });
980
+ case "Literal":
981
+ return node.value;
982
+ case "TemplateLiteral":
983
+ if (node.expressions.length > 0)
984
+ throw new Error(`template interpolation not allowed in ${path}`);
985
+ return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join("");
986
+ case "UnaryExpression":
987
+ if (node.operator === "-" && node.argument?.type === "Literal" && typeof node.argument.value === "number") {
988
+ return -node.argument.value;
989
+ }
990
+ throw new Error(`only negative-number unary allowed in ${path}`);
991
+ default:
992
+ throw new Error(`non-literal node type in ${path}: ${node.type}`);
993
+ }
994
+ }
995
+ function propertyKey(node, path) {
996
+ if (node.type === "Identifier")
997
+ return node.name;
998
+ if (node.type === "Literal" && (typeof node.value === "string" || typeof node.value === "number"))
999
+ return String(node.value);
1000
+ throw new Error(`unsupported key type in ${path}: ${node.type}`);
1001
+ }
1002
+ function validateMeta(meta) {
1003
+ if (!meta || typeof meta !== "object")
1004
+ throw new Error("meta must be an object");
1005
+ const value = meta;
1006
+ if (typeof value.name !== "string" || !value.name.trim())
1007
+ throw new Error("meta.name must be a non-empty string");
1008
+ if (typeof value.description !== "string" || !value.description.trim())
1009
+ throw new Error("meta.description must be a non-empty string");
1010
+ if (value.model !== undefined && typeof value.model !== "string")
1011
+ throw new Error("meta.model must be a string");
1012
+ if (value.phases !== undefined) {
1013
+ if (!Array.isArray(value.phases))
1014
+ throw new Error("meta.phases must be an array");
1015
+ for (const phase of value.phases) {
1016
+ if (!phase || typeof phase !== "object" || typeof phase.title !== "string") {
1017
+ throw new Error("each meta phase must have a title string");
1018
+ }
1019
+ }
1020
+ }
1021
+ }
1022
+ function createLimiter(limit) {
1023
+ let active = 0;
1024
+ const queue = [];
1025
+ const next = () => {
1026
+ active--;
1027
+ queue.shift()?.();
1028
+ };
1029
+ return async (fn) => {
1030
+ if (active >= limit)
1031
+ await new Promise((resolve) => queue.push(resolve));
1032
+ active++;
1033
+ try {
1034
+ return await fn();
1035
+ }
1036
+ finally {
1037
+ next();
1038
+ }
1039
+ };
1040
+ }
1041
+ function defaultAgentLabel(phase, index) {
1042
+ return phase ? `${phase} agent ${index}` : `agent ${index}`;
1043
+ }
1044
+ /**
1045
+ * Stable identity hash for a checkpoint() call — a cache miss on resume when
1046
+ * anything that could change its outcome changes. Must cover every
1047
+ * CheckpointOptions field that participates in the outcome, not just
1048
+ * promptText/kind/choices:
1049
+ * - `default` and `headless` decide the reply in the headless (no `confirm`
1050
+ * threaded in) path — a script edited to change either must not resume
1051
+ * with the OLD default/behavior's stale journaled reply.
1052
+ * - `timeoutMs` bounds the interactive prompt; a host `confirm` may itself
1053
+ * fall back to `default` when the human doesn't answer in time, so it can
1054
+ * also affect the outcome and is included for the same reason.
1055
+ * NOTE: widening this hash is a one-time invalidation of any checkpoint
1056
+ * answers already persisted under the old (narrower) hash — on the first
1057
+ * resume after upgrading, those checkpoints will cache-miss and re-prompt (or
1058
+ * re-apply the default) once, live. That's intentional: a silently-stale
1059
+ * cached decision from before the identity surface was fixed is worse than a
1060
+ * one-time re-ask.
1061
+ */
1062
+ function hashCheckpoint(promptText, options) {
1063
+ const identity = JSON.stringify({
1064
+ promptText,
1065
+ kind: options.kind ?? "confirm",
1066
+ choices: options.choices ?? null,
1067
+ default: options.default ?? null,
1068
+ headless: options.headless ?? "default",
1069
+ timeoutMs: options.timeoutMs ?? null,
1070
+ });
1071
+ return createHash("sha256").update(identity).digest("hex");
1072
+ }
1073
+ function hashAgentCall(prompt, model, phase, options, agentDefKey) {
1074
+ const identity = JSON.stringify({
1075
+ prompt,
1076
+ model: model ?? null,
1077
+ tier: options.tier ?? null,
1078
+ phase: phase ?? null,
1079
+ agentType: options.agentType ?? null,
1080
+ // Resolved definition (tools/model/prompt) so editing an agent .md invalidates
1081
+ // this call's cached result on a later resume.
1082
+ agentDef: agentDefKey,
1083
+ schema: options.schema ?? null,
1084
+ });
1085
+ return createHash("sha256").update(identity).digest("hex");
1086
+ }
1087
+ function buildAgentInstructions(phase, options, def, resolvedIsolation) {
1088
+ const lines = [];
1089
+ // A resolved agentType binds a real role prompt (the definition body). Only
1090
+ // fall back to the prose hint when the agentType named no known definition.
1091
+ if (def?.prompt)
1092
+ lines.push(def.prompt);
1093
+ else if (options.agentType)
1094
+ lines.push(`Act as workflow subagent type: ${options.agentType}`);
1095
+ if (phase)
1096
+ lines.push(`Workflow phase: ${phase}`);
1097
+ // Use resolvedIsolation so the annotation fires whether isolation came from
1098
+ // the call site or from the agentDef's isolation field.
1099
+ if (resolvedIsolation)
1100
+ lines.push(`Requested isolation: ${resolvedIsolation}`);
1101
+ // Note: options.model is applied for real via the session, not injected as prose.
1102
+ return lines.length ? lines.join("\n\n") : undefined;
1103
+ }
1104
+ function isEmptyTextAgentResult(result, schema) {
1105
+ return schema === undefined && typeof result === "string" && result.trim().length === 0;
1106
+ }
1107
+ function estimateTokens(value) {
1108
+ return Math.ceil(JSON.stringify(value ?? "").length / 4);
1109
+ }
1110
+ function normalizeConcurrency(value) {
1111
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1)
1112
+ return 1;
1113
+ return Math.min(MAX_CONCURRENCY, Math.floor(value));
1114
+ }
1115
+ function normalizeAgentRetries(value) {
1116
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
1117
+ return 0;
1118
+ return Math.min(MAX_AGENT_RETRIES, Math.floor(value));
1119
+ }
1120
+ /**
1121
+ * Run a promise with a timeout.
1122
+ *
1123
+ * `onTimeout` fires when the deadline hits, BEFORE the timeout rejection wins the
1124
+ * race — the caller uses it to abort the underlying work (e.g. the subagent
1125
+ * session) so it can release its resources instead of streaming on in the
1126
+ * background with the whole session graph (messages, etc.) retained (#109). The
1127
+ * losing promise still settles later; the caller must swallow its rejection.
1128
+ */
1129
+ async function withTimeout(promise, ms, label, onTimeout) {
1130
+ if (ms === null)
1131
+ return promise;
1132
+ let timeoutId;
1133
+ const timeoutPromise = new Promise((_, reject) => {
1134
+ timeoutId = setTimeout(() => {
1135
+ try {
1136
+ onTimeout?.();
1137
+ }
1138
+ catch {
1139
+ // Best-effort cleanup; never let it mask the timeout error.
1140
+ }
1141
+ reject(new WorkflowError(`Agent "${label}" timed out after ${ms}ms; raise or omit timeoutMs/agentTimeoutMs to allow longer runs`, WorkflowErrorCode.AGENT_TIMEOUT, { recoverable: true }));
1142
+ }, ms);
1143
+ });
1144
+ try {
1145
+ return await Promise.race([promise, timeoutPromise]);
1146
+ }
1147
+ finally {
1148
+ if (timeoutId)
1149
+ clearTimeout(timeoutId);
1150
+ }
1151
+ }