@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,1615 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHash } from "node:crypto";
3
+ import vm from "node:vm";
4
+ import type { Node } from "acorn";
5
+ import { parse } from "acorn";
6
+ import type { TSchema } from "typebox";
7
+ import type { AgentUsage } from "./agent.js";
8
+ import { type AgentRunOptions, WorkflowAgent, type WorkflowAgentOptions } from "./agent.js";
9
+ import type { AgentHistoryEntry } from "./agent-history.js";
10
+ import {
11
+ type AgentDefinition,
12
+ type AgentRegistry,
13
+ agentDefinitionKey,
14
+ loadAgentRegistry,
15
+ resolveAgentType,
16
+ } from "./agent-registry.js";
17
+ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENT_RETRIES, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from "./config.js";
18
+ import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
19
+ import { createWorkflowLogger } from "./logger.js";
20
+ import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
21
+ import { createAgentStoreTools, SharedStore } from "./shared-store.js";
22
+ import { WORKFLOW_CAPABILITY_CONTRACT, type WorkflowRuntimeImplementations } from "./workflow-capability-contract.js";
23
+ import { createWorktree, removeWorktree, type Worktree } from "./worktree.js";
24
+
25
+ /**
26
+ * Batch-scoped cancellation for a single parallel()/pipeline() fan-out. When a
27
+ * fan-out's agent() calls reserve past maxAgents, the breaching call throws and
28
+ * the whole fan-out rejects — but agents already reserved and queued behind the
29
+ * limiter would otherwise keep draining and spending. parallel()/pipeline()
30
+ * establish a fresh store per call via fanoutScope.run(); agent() captures the
31
+ * nearest enclosing store synchronously (before suspending on the limiter) so a
32
+ * still-queued agent can bail once ITS OWN fan-out breaches, without touching
33
+ * sibling fan-outs running concurrently or an enclosing fan-out when this one is
34
+ * nested inside it (each nesting level gets its own store via ALS scoping).
35
+ *
36
+ * Scope note: cancellation is bounded PER breaching fan-out, not run-global — a
37
+ * deliberate tradeoff. Deep-sixing the earlier run-global flag was required
38
+ * because it wrongly cancelled an innocent, independently-caught sibling batch.
39
+ * The consequence: if one fan-out breaches while an unrelated in-cap sibling or
40
+ * a nested inner fan-out is mid-flight, that other batch is NOT cancelled and
41
+ * finishes its already-reserved agents (still capped at maxAgents total). Only
42
+ * the breaching fan-out's own queue is short-circuited.
43
+ */
44
+ const fanoutScope = new AsyncLocalStorage<{ cancelled: boolean }>();
45
+
46
+ export interface WorkflowMetaPhase {
47
+ title: string;
48
+ detail?: string;
49
+ model?: string;
50
+ }
51
+
52
+ export interface WorkflowMeta {
53
+ name: string;
54
+ description: string;
55
+ phases?: WorkflowMetaPhase[];
56
+ /** Default model for agents whose phase has no route and that set no model/tier. */
57
+ model?: string;
58
+ }
59
+
60
+ /** One cached agent() result, keyed by its deterministic call index. */
61
+ export interface JournalEntry {
62
+ index: number;
63
+ /**
64
+ * The runId of the frame (top-level run, or a nested workflow()'s own run)
65
+ * this entry's `index` is scoped to. A nested workflow() restarts its own
66
+ * callSeq at 0, so `index` alone collides between a parent's and a child's
67
+ * same-numbered calls — see `resumeJournal`'s key format, which namespaces
68
+ * on this the same way SharedStore's deltaKey already does. Absent on
69
+ * journal entries persisted before this field existed; such legacy entries
70
+ * are treated as belonging to the run's own top-level runId (see
71
+ * WorkflowManager.resume()) — a legacy entry that actually belonged to a
72
+ * nested frame simply cache-misses on resume (safe degradation: it re-runs
73
+ * live, it does not apply to the wrong call).
74
+ */
75
+ runId?: string;
76
+ /** sha256 of the call's identity (prompt + model + phase + agentType + schema). */
77
+ hash: string;
78
+ result: unknown;
79
+ /**
80
+ * Per-agent write delta (keys set by this agent) for additive replay on resume.
81
+ * Replaces the former full-map snapshot to fix parallel-agent ordering: applying
82
+ * deltas in callSeq order accumulates all agents' writes correctly regardless of
83
+ * which agent finished first. Absent on older journal entries.
84
+ */
85
+ storeDelta?: Record<string, unknown>;
86
+ }
87
+
88
+ /**
89
+ * Global resources shared across a run and any workflow() nested inside it, so
90
+ * the 16-concurrent / 1000-total caps and the token budget hold across nesting
91
+ * instead of each level getting its own limiter and counters.
92
+ */
93
+ export interface SharedRuntime {
94
+ limiter: <T>(fn: () => Promise<T>) => Promise<T>;
95
+ agentCount: number;
96
+ spent: number;
97
+ tokenUsage: { input: number; output: number; total: number; cost: number; cacheRead: number; cacheWrite: number };
98
+ depth: number;
99
+ /**
100
+ * Monotonic count of every workflow() call anywhere in this run tree,
101
+ * regardless of nesting depth — used (instead of `depth`) to build each
102
+ * nested run's runId suffix (see workflowFn below). `depth` alone is NOT
103
+ * enough: it returns to 0 after each nested call finishes, so two
104
+ * SEQUENTIAL nested workflow() calls at the same depth (`await
105
+ * workflow('a'); await workflow('b')`) would otherwise both compute the
106
+ * exact same `${runId}-nested1` suffix. That collision matters because a
107
+ * child's own callSeq restarts at 0, so its deltaKey (`${childRunId}:
108
+ * ${callIndex}`) — the same id used as SharedStore's delta key AND as the
109
+ * onAgentStart/onAgentEnd/onAgentHistory event id (see item 2's identity
110
+ * model) — would collide between the two children's same-callIndex calls.
111
+ * That's a real, not just theoretical, collision risk: an un-awaited
112
+ * stray agent() call from the first child (still in SharedRuntime.inFlight,
113
+ * not yet drained — only the top-level frame drains) can still be pending
114
+ * when the second child starts and mints the very same id.
115
+ */
116
+ nestedCallSeq: number;
117
+ /**
118
+ * Fires exactly once a run-fatal error is determined: an error that escaped
119
+ * the TOP-level script's own execution completely uncaught (see runWorkflow's
120
+ * catch below) — i.e. nothing anywhere in the call chain, at any nesting
121
+ * depth, caught it, so the run really is failing. Shared (not per-nesting-
122
+ * level) so a nested workflow()'s in-flight siblings wind down too, the
123
+ * instant the fate of the WHOLE run is sealed — not the instant any single
124
+ * fan-out rejects, which would break parallel()'s null-on-recoverable-error
125
+ * contract and a script's own try/catch around agent()/workflow(). Every
126
+ * agent() call (this level and any nested workflow()) links its per-attempt
127
+ * AbortController to this signal, alongside the caller's own options.signal,
128
+ * so already-in-flight sibling subagent sessions actually abort instead of
129
+ * running to completion on a run whose outcome is already decided. Wrapped
130
+ * in an AbortController (not a bare boolean) purely so workflow.ts never
131
+ * needs write access to the caller-owned options.signal/AbortController.
132
+ */
133
+ runFatalController: AbortController;
134
+ /**
135
+ * Every agent() promise spawned anywhere in this run (this level's script
136
+ * and any nested workflow()'s), added on call and removed on settle. Drained
137
+ * (awaited to completion) by the TOP-level runWorkflow's finally, before the
138
+ * SharedStore is disposed — so a script that forgets to `await agent(...)`
139
+ * can never have that call still mutating the store (or reporting results)
140
+ * after the run has been marked complete and torn down. See the drain below.
141
+ */
142
+ inFlight: Set<Promise<unknown>>;
143
+ }
144
+
145
+ /** Runtime instrumentation for workflow boundaries, quality helpers, and control attempts. */
146
+ export type WorkflowRuntimeEvent =
147
+ | { type: "phase"; title: string; budget: number | null }
148
+ | { type: "workflow"; stage: "start" | "end"; name: string; args: unknown }
149
+ | { type: "quality"; stage: "start" | "end"; helper: "verify" | "judgePanel" | "completenessCheck" }
150
+ | { type: "control-attempt"; helper: "retry" | "gate"; attempt: number; accepted: boolean };
151
+
152
+ /** Minimal injected agent surface used by the workflow runtime and deterministic tests. */
153
+ export interface WorkflowAgentRunner {
154
+ run(prompt: string, options?: AgentRunOptions<TSchema>): Promise<unknown>;
155
+ }
156
+
157
+ export interface WorkflowRunOptions extends WorkflowAgentOptions {
158
+ args?: unknown;
159
+ agent?: WorkflowAgentRunner;
160
+ /** The session's main model (provider/id), shown in /workflows for default agents. */
161
+ mainModel?: string;
162
+ /**
163
+ * Named subagent definitions for `agent({ agentType })`. Snapshotted once per
164
+ * run for determinism. Defaults to scanning `.pi/agents` (project) +
165
+ * `~/.pi/agent/agents` (user, primary) + `~/.pi/agents` (user, deprecated
166
+ * fallback). Injectable for tests.
167
+ */
168
+ agentRegistry?: AgentRegistry;
169
+ concurrency?: number;
170
+ /** Retry attempts after a recoverable agent failure. Default 0. */
171
+ agentRetries?: number;
172
+ tokenBudget?: number | null;
173
+ signal?: AbortSignal;
174
+ /** Maximum number of agents allowed in this run. Default: 1000 */
175
+ maxAgents?: number;
176
+ /** Timeout per agent in milliseconds. null/omitted means no hard timeout. */
177
+ agentTimeoutMs?: number | null;
178
+ /** Whether to persist logs to disk. Default: true */
179
+ persistLogs?: boolean;
180
+ /** Run ID for persistence. Auto-generated if not provided. */
181
+ runId?: string;
182
+ /**
183
+ * Resume: cached agent/checkpoint results keyed by `${runId}:${callIndex}`
184
+ * — the same namespacing SharedStore's deltaKey uses — so a nested
185
+ * workflow() call's callIndex-0 (its callSeq restarts at 0) can never
186
+ * collide with the parent's own callIndex-0 entry. A legacy entry with no
187
+ * `runId` (persisted before namespacing existed) is looked up under the
188
+ * run's own top-level runId only; see `JournalEntry.runId`.
189
+ */
190
+ resumeJournal?: Map<string, JournalEntry>;
191
+ /** Resume: the run being resumed (informational; enables resume mode). */
192
+ resumeFromRunId?: string;
193
+ /** Called after each live agent completes so the caller can persist the journal. */
194
+ onAgentJournal?: (entry: JournalEntry) => void;
195
+ /**
196
+ * Called once per FAILED-AND-RETRIED attempt (not the final attempt of an
197
+ * agent() call, which reports its own tokens via onAgentEnd as before),
198
+ * with that attempt's token cost. recordTokens() already folds a retried
199
+ * attempt's spend into shared.spent/shared.tokenUsage (so the run-wide
200
+ * budget was never leaky) — but onAgentEnd only ever reports the FINAL
201
+ * attempt's tokens, so a caller accumulating a persisted total purely from
202
+ * onAgentEnd (see WorkflowManager) would under-count by exactly the
203
+ * wasted retried attempts' spend. This is a separate, silent channel
204
+ * specifically so retried-attempt spend can be accounted for without
205
+ * changing onAgentEnd's one-call-per-agent-call cadence (a contract other
206
+ * code depends on).
207
+ */
208
+ onRetrySpend?: (tokens: number) => void;
209
+ /** Internal: shared runtime inherited by a nested workflow() call. */
210
+ sharedRuntime?: SharedRuntime;
211
+ /**
212
+ * Seed the FRESH SharedRuntime's cumulative spend/tokenUsage counters from a
213
+ * previously-persisted total (resume()), instead of starting at zero. Used
214
+ * only on the fresh-SharedRuntime branch below — never applied when
215
+ * `sharedRuntime` is supplied (a nested workflow() call inherits the
216
+ * parent's live, already-correct counters and must not be re-seeded).
217
+ * Without this, a resumed run's tokenBudget cap silently resets: it would
218
+ * enforce the ceiling against only what THIS execution spends, ignoring
219
+ * whatever was already spent before the pause.
220
+ */
221
+ initialTokenUsage?: {
222
+ input: number;
223
+ output: number;
224
+ total: number;
225
+ cost: number;
226
+ cacheRead: number;
227
+ cacheWrite: number;
228
+ };
229
+ /**
230
+ * Shared store for this run. One instance is created per top-level run and
231
+ * propagated into nested workflow() calls. Pass an existing instance to share
232
+ * state across a parent and child run; omit to create a fresh isolated store.
233
+ */
234
+ sharedStore?: SharedStore;
235
+ /** Resolve a saved-workflow name to its script, enabling `workflow('name', args)`. */
236
+ loadSavedWorkflow?: (name: string) => string | undefined;
237
+ /**
238
+ * Ask the human a checkpoint() question and resolve to their reply. Threaded from
239
+ * a UI-bearing tool context. Absent => headless: checkpoint() takes its declared
240
+ * default (and journals it), so a detached/background run never hangs.
241
+ */
242
+ confirm?: (promptText: string, options: CheckpointOptions) => Promise<unknown>;
243
+ onLog?: (message: string) => void;
244
+ onPhase?: (title: string) => void;
245
+ /** Runtime behavior trace used by diagnostics and comprehension evidence. */
246
+ onRuntimeEvent?: (event: WorkflowRuntimeEvent) => void;
247
+ onAgentStart?: (event: { id: string; label: string; phase?: string; prompt: string; model?: string }) => void;
248
+ onAgentEnd?: (event: {
249
+ /**
250
+ * Unique per agent() CALL (not per label — concurrent agents routinely
251
+ * share a label, e.g. parallel()'s default `"${phase} agent N"` labels or
252
+ * an author-supplied label reused across a fan-out). Stable across this
253
+ * call's start/end/history events. Callers must key any per-agent
254
+ * bookkeeping on this, never on label, to avoid misattributing a
255
+ * concurrent same-label agent's event to the wrong entry.
256
+ */
257
+ id: string;
258
+ label: string;
259
+ phase?: string;
260
+ result: unknown;
261
+ tokens?: number;
262
+ tokenUsage?: AgentUsage;
263
+ worktree?: string;
264
+ model?: string;
265
+ error?: string;
266
+ errorCode?: WorkflowErrorCode;
267
+ recoverable?: boolean;
268
+ }) => void;
269
+ onAgentHistory?: (event: { id: string; label: string; phase?: string; history: AgentHistoryEntry[] }) => void;
270
+ onTokenUsage?: (usage: {
271
+ input: number;
272
+ output: number;
273
+ total: number;
274
+ cost: number;
275
+ cacheRead?: number;
276
+ cacheWrite?: number;
277
+ }) => void;
278
+ }
279
+
280
+ export interface WorkflowRunResult<T = unknown> {
281
+ meta: WorkflowMeta;
282
+ result: T;
283
+ logs: string[];
284
+ phases: string[];
285
+ agentCount: number;
286
+ durationMs: number;
287
+ runId?: string;
288
+ tokenUsage?: {
289
+ input: number;
290
+ output: number;
291
+ total: number;
292
+ cost: number;
293
+ cacheRead?: number;
294
+ cacheWrite?: number;
295
+ };
296
+ }
297
+
298
+ export interface AgentOptions<TSchemaDef extends TSchema | undefined = TSchema | undefined> {
299
+ label?: string;
300
+ phase?: string;
301
+ schema?: TSchemaDef;
302
+ /**
303
+ * Run this agent on a specific model (`provider/modelId` or a bare `modelId`).
304
+ * The workflow author chooses per-agent models per the routing policy in the
305
+ * tool guidelines (e.g. a lighter model for exploration, the main model for
306
+ * analysis). When omitted, the session's main model is used.
307
+ */
308
+ model?: string;
309
+ /**
310
+ * Coarse model tier ("small" | "medium" | "big"), resolved from the user's
311
+ * model-tiers config (see /workflows-models). An explicit `model` takes
312
+ * precedence; a tier takes precedence over the phase model. When the tier has
313
+ * no configured entry it falls back to the session's main model.
314
+ */
315
+ tier?: string;
316
+ isolation?: "worktree";
317
+ /**
318
+ * Name of a registered subagent definition (`.pi/agents/<name>.md`, project >
319
+ * user). Binds that definition's tool allow/denylist, model, and body prompt
320
+ * to this agent. An explicit `model` overrides the definition's model; the
321
+ * definition's model overrides `tier`/phase. An unknown name logs a warning
322
+ * and falls back to default tools/model (with the name as a prose hint).
323
+ */
324
+ agentType?: string;
325
+ /** Override timeout for this specific agent. null means no hard timeout. */
326
+ timeoutMs?: number | null;
327
+ /** Retry attempts after a recoverable failure for this specific agent. */
328
+ retries?: number;
329
+ }
330
+
331
+ /** Options for a human checkpoint() — a deterministic, journaled, replayable gate. */
332
+ export interface CheckpointOptions {
333
+ /** Reply used when no UI is available (headless/background) and headless != "abort". */
334
+ default?: unknown;
335
+ /** Headless behavior: "default" (take `default`/true) or "abort" (throw). Default "default". */
336
+ headless?: "default" | "abort";
337
+ /** Confirm | free-text input | pick-one. Affects the hash and the UI widget. */
338
+ kind?: "confirm" | "input" | "select";
339
+ /** For kind "select". */
340
+ choices?: string[];
341
+ /** Per-checkpoint timeout in ms for the interactive prompt. */
342
+ timeoutMs?: number;
343
+ }
344
+
345
+ interface RuntimeState {
346
+ currentPhase?: string;
347
+ /**
348
+ * Per-phase soft sub-budgets carved from the run total: phase title -> the
349
+ * ceiling and the run-wide spent at the moment the budget was declared. A phase
350
+ * exceeding its ceiling throws TOKEN_BUDGET_EXHAUSTED while the run's overall
351
+ * budget is untouched. Soft gate (like the global one): spent accrues after each
352
+ * agent, so an in-flight wave may overshoot slightly.
353
+ */
354
+ phaseBudgets: Map<string, { budget: number; startSpent: number; warned: boolean }>;
355
+ logs: string[];
356
+ phases: string[];
357
+ /** Monotonic, assigned at lexical agent() call time — the stable resume key. */
358
+ callSeq: number;
359
+ /**
360
+ * Index of the first call that missed the resume journal (changed or new).
361
+ * Longest-unchanged-prefix resume: a cached result is replayed only while
362
+ * callIndex < firstMiss; once a call misses, it AND everything after run live.
363
+ */
364
+ firstMiss: number;
365
+ }
366
+
367
+ type AnyNode = Node & { [key: string]: any; start: number; end: number };
368
+
369
+ // Parse-time author hint (fast feedback). The real enforcement is DETERMINISM_PRELUDE.
370
+ const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
371
+
372
+ /**
373
+ * Runtime determinism hardening, run inside the vm realm BEFORE the user script.
374
+ * It neuters the nondeterministic builtins that would break resume (they'd make a
375
+ * re-run produce different values than the cached journal):
376
+ * - Math.random() -> throws
377
+ * - Date.now() -> throws
378
+ * - Date() / new Date() -> throws (no-arg); new Date(arg) still works
379
+ * Using the vm realm's own Math/Date/Reflect (not host objects) means this adds
380
+ * no host-`Function` escape. Note: vm is not a security sandbox — an injected
381
+ * bridge function's `.constructor` is still the host Function, so a determined
382
+ * script could bypass this. The guard is best-effort against ACCIDENTAL
383
+ * nondeterminism from trusted (user / guided-LLM) scripts, not a security wall.
384
+ */
385
+ const DETERMINISM_PRELUDE = [
386
+ '"use strict";',
387
+ 'Math.random = () => { throw new Error("Math.random() is unavailable in a workflow (it breaks resume); pass randomness via args or vary by index"); };',
388
+ "{",
389
+ " const RealDate = Date;",
390
+ ' const fail = (w) => { throw new Error(w + " is unavailable in a workflow (it breaks resume); pass a timestamp via args"); };',
391
+ " const SafeDate = function (...a) {",
392
+ ' if (!new.target) fail("Date()");',
393
+ ' if (a.length === 0) fail("new Date()");',
394
+ " return Reflect.construct(RealDate, a, SafeDate);",
395
+ " };",
396
+ " SafeDate.UTC = RealDate.UTC;",
397
+ " SafeDate.parse = RealDate.parse;",
398
+ ' SafeDate.now = () => fail("Date.now()");',
399
+ " SafeDate.prototype = RealDate.prototype;",
400
+ " globalThis.Date = SafeDate;",
401
+ "}",
402
+ ].join("\n");
403
+
404
+ export async function runWorkflow<T = unknown>(
405
+ script: string,
406
+ options: WorkflowRunOptions = {},
407
+ ): Promise<WorkflowRunResult<T>> {
408
+ const started = Date.now();
409
+ const { meta, body } = parseWorkflowScript(script);
410
+ // Per-phase model routing from meta.phases[].model, with meta.model as the default.
411
+ const routingConfig = parseModelRoutingFromMeta(meta.phases, meta.model);
412
+ const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
413
+ const agentTimeoutMs = options.agentTimeoutMs !== undefined ? options.agentTimeoutMs : DEFAULT_AGENT_TIMEOUT_MS;
414
+ const runId = options.runId ?? `run-${started.toString(36)}`;
415
+ const baseCwd = options.cwd ?? process.cwd();
416
+ // Snapshot the agentType registry ONCE per run so two agent() calls can't
417
+ // observe a mid-run edit (determinism); a later resume re-reads it.
418
+ const agentRegistry = options.agentRegistry ?? loadAgentRegistry(baseCwd);
419
+
420
+ // Initialize logger
421
+ const logger = createWorkflowLogger({
422
+ runId,
423
+ cwd: options.cwd ?? process.cwd(),
424
+ persist: options.persistLogs ?? true,
425
+ onLog: options.onLog,
426
+ });
427
+
428
+ const state: RuntimeState = {
429
+ logs: [],
430
+ // When the script declares meta.phases, default the current phase to the
431
+ // first one so agents created before any explicit phase() call still group
432
+ // under a declared phase instead of an orphan "(no phase)" bucket. An
433
+ // explicit phase() (or agent({ phase })) overrides this.
434
+ phases: meta.phases?.[0]?.title ? [meta.phases[0].title] : [],
435
+ currentPhase: meta.phases?.[0]?.title,
436
+ phaseBudgets: new Map(),
437
+ callSeq: 0,
438
+ firstMiss: Number.POSITIVE_INFINITY,
439
+ };
440
+
441
+ const agentRunner = options.agent ?? new WorkflowAgent(options);
442
+ const concurrency = normalizeConcurrency(
443
+ options.concurrency ?? Math.max(1, (globalThis.navigator?.hardwareConcurrency ?? 8) - 2),
444
+ );
445
+ // Global caps + budget are shared with any nested workflow() so they hold across nesting.
446
+ // options.initialTokenUsage (resume() only) seeds spent/tokenUsage so the
447
+ // tokenBudget ceiling holds cumulatively across a pause/resume cycle instead
448
+ // of resetting to zero (see WorkflowRunOptions.initialTokenUsage). Deliberately
449
+ // NOT applied when options.sharedRuntime is supplied — that branch inherits a
450
+ // parent workflow()'s already-live counters, which must not be re-seeded.
451
+ //
452
+ // agentCount is NOT seeded here, unlike spent/tokenUsage — and doesn't need
453
+ // to be: resume() always replays the whole script from callIndex 0, and
454
+ // agent()'s `shared.agentCount++` fires unconditionally for every call
455
+ // (cache-hit replay or live) before the replay-vs-live branch runs. That
456
+ // replay alone reconstructs the correct cumulative count in this fresh
457
+ // SharedRuntime by the time any new live agent executes, so maxAgents stays
458
+ // a genuine cumulative cap across resume with no extra seeding. Token spend
459
+ // needs seeding precisely because its cache-hit branch deliberately does NOT
460
+ // re-run recordTokens() (to avoid double-counting already-spent tokens) —
461
+ // there is no replay-based reconstruction for it the way there is for count.
462
+ const shared: SharedRuntime = options.sharedRuntime ?? {
463
+ limiter: createLimiter(concurrency),
464
+ agentCount: 0,
465
+ spent: options.initialTokenUsage?.total ?? 0,
466
+ tokenUsage: options.initialTokenUsage
467
+ ? { ...options.initialTokenUsage }
468
+ : { input: 0, output: 0, total: 0, cost: 0, cacheRead: 0, cacheWrite: 0 },
469
+ depth: 0,
470
+ nestedCallSeq: 0,
471
+ runFatalController: new AbortController(),
472
+ inFlight: new Set<Promise<unknown>>(),
473
+ };
474
+ const limiter = shared.limiter;
475
+ // This frame created `shared` fresh (rather than inheriting a parent
476
+ // workflow()'s) — i.e. it's the true top-level run, the only frame allowed
477
+ // to declare the run's fate sealed (see SharedRuntime.runFatalController) or
478
+ // drain/dispose the SharedStore. A nested workflow() call always passes both
479
+ // sharedRuntime and sharedStore together (see workflowFn below), so this is
480
+ // equivalent to `!options.sharedStore` — used at both choke points below.
481
+ const isTopLevelRun = !options.sharedRuntime;
482
+
483
+ // One store instance per run; nested workflow() calls inherit the parent's store
484
+ // so all agents across nesting levels share the same key-value space.
485
+ const store: SharedStore = options.sharedStore ?? new SharedStore();
486
+
487
+ const log = (message: string) => {
488
+ const text = String(message);
489
+ state.logs.push(text);
490
+ logger.log(text);
491
+ };
492
+
493
+ const phase = (title: string, phaseOptions?: { budget?: number }) => {
494
+ state.currentPhase = title;
495
+ if (!state.phases.includes(title)) state.phases.push(title);
496
+ // Carve a soft sub-budget from the run total for work done under this phase.
497
+ // Re-declaring re-bases from the current spent (idempotent across resume: the
498
+ // script re-runs phase() and the ceiling is recomputed from live spent).
499
+ if (typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0) {
500
+ state.phaseBudgets.set(title, { budget: phaseOptions.budget, startSpent: shared.spent, warned: false });
501
+ }
502
+ options.onPhase?.(title);
503
+ options.onRuntimeEvent?.({
504
+ type: "phase",
505
+ title,
506
+ budget: typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0 ? phaseOptions.budget : null,
507
+ });
508
+ };
509
+
510
+ const budget = Object.freeze({
511
+ total: options.tokenBudget ?? null,
512
+ spent: () => shared.spent,
513
+ remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - shared.spent)),
514
+ });
515
+
516
+ const agentLimitError = () =>
517
+ new WorkflowError(
518
+ `Agent limit exceeded (${maxAgents}). Use maxAgents option to increase the limit.`,
519
+ WorkflowErrorCode.AGENT_LIMIT_EXCEEDED,
520
+ { recoverable: false },
521
+ );
522
+
523
+ // True on an intentional external abort (pause/stop/Esc, via options.signal)
524
+ // OR once this run's fate has been sealed (shared.runFatalController — see
525
+ // its doc comment). Every abort check in this file goes through this so the
526
+ // two sources compose identically everywhere instead of only some call
527
+ // sites remembering to check the second one.
528
+ const isAborted = () => Boolean(options.signal?.aborted || shared.runFatalController.signal.aborted);
529
+
530
+ const throwIfAborted = () => {
531
+ if (isAborted()) {
532
+ throw new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
533
+ }
534
+ };
535
+
536
+ const agent = (prompt: string, agentOptions: AgentOptions = {}): Promise<unknown> => {
537
+ // Track every call (awaited or not) so the top-level run can drain
538
+ // outstanding calls before completing (see SharedRuntime.inFlight and the
539
+ // drain in the finally below) — this is what stops a forgotten `await`
540
+ // from letting an agent mutate state after the run is torn down.
541
+ const call = agentImpl(prompt, agentOptions);
542
+ shared.inFlight.add(call);
543
+ // Attaching a handler here (independent of whatever the script itself does
544
+ // with the returned promise) also means an un-awaited call's eventual
545
+ // rejection never becomes a process-crashing unhandled rejection.
546
+ call.catch(() => {}).finally(() => shared.inFlight.delete(call));
547
+ return call;
548
+ };
549
+
550
+ const agentImpl = async (prompt: string, agentOptions: AgentOptions = {}) => {
551
+ throwIfAborted();
552
+
553
+ // Capture the enclosing parallel()/pipeline() fan-out's cancellation batch
554
+ // (if any) synchronously, while the ALS context of the caller is still
555
+ // active — i.e. before suspending on the limiter below. The limiter body
556
+ // closes over this so a still-queued agent can bail once its OWN fan-out
557
+ // breaches the cap, without affecting sibling or outer fan-outs.
558
+ const batch = fanoutScope.getStore();
559
+
560
+ // Check agent limit. A fan-out that overshoots the cap has already reserved
561
+ // and queued up to `maxAgents` agents; the breaching call throws here, and
562
+ // parallel()/pipeline() mark their own batch cancelled so the already-queued
563
+ // agents short-circuit before their real API call (see the limiter body).
564
+ if (shared.agentCount >= maxAgents) {
565
+ throw agentLimitError();
566
+ }
567
+
568
+ if (budget.total !== null && budget.remaining() <= 0) {
569
+ throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
570
+ recoverable: false,
571
+ });
572
+ }
573
+
574
+ const assignedPhase = agentOptions.phase ?? state.currentPhase;
575
+
576
+ // Per-phase soft sub-budget gate: a noisy phase can exhaust its own ceiling
577
+ // without touching the run's overall budget. Soft (spent accrues post-agent),
578
+ // warns once at ~80%, throws at 100%. Scripts can try/catch around a phase's
579
+ // work so later phases still proceed.
580
+ if (assignedPhase) {
581
+ const pb = state.phaseBudgets.get(assignedPhase);
582
+ if (pb) {
583
+ const phaseSpent = shared.spent - pb.startSpent;
584
+ if (phaseSpent >= pb.budget) {
585
+ throw new WorkflowError(
586
+ `phase "${assignedPhase}" token sub-budget exhausted (${pb.budget})`,
587
+ WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED,
588
+ { recoverable: false },
589
+ );
590
+ }
591
+ if (!pb.warned && phaseSpent >= pb.budget * 0.8) {
592
+ pb.warned = true;
593
+ log(`phase "${assignedPhase}" at ${Math.round((phaseSpent / pb.budget) * 100)}% of its token sub-budget`);
594
+ }
595
+ }
596
+ }
597
+
598
+ const requestedLabel = agentOptions.label?.trim();
599
+
600
+ // Resolve a named agentType to its bound definition (tools/model/prompt).
601
+ const agentDef = resolveAgentType(agentOptions.agentType, agentRegistry);
602
+ if (agentOptions.agentType && !agentDef) {
603
+ log(`unknown agentType "${agentOptions.agentType}"; using default tools/model`);
604
+ }
605
+
606
+ // Model precedence: explicit agentOptions.model > agentType.model > tier > phase model.
607
+ // The "explicit-level" model is opts.model, else the definition's model — either
608
+ // beats tier/phase. When only a tier is set, pass undefined here so the tier (not
609
+ // the phase model) decides inside WorkflowAgent.run().
610
+ const explicitModel = agentOptions.model ?? agentDef?.model;
611
+ const modelSpec =
612
+ explicitModel ?? (agentOptions.tier ? undefined : resolveModelForPhase(assignedPhase, routingConfig));
613
+ // For display in /workflows: the model this agent runs on — its explicit/phase
614
+ // spec, else the session's main model. The real resolved id overrides this via
615
+ // onModelResolved once the subagent session is created.
616
+ let displayModel = modelSpec ?? options.mainModel;
617
+
618
+ // Deterministic resume key: assigned at lexical call time, before the limiter,
619
+ // so parallel()/pipeline() fan-out is reproducible for a fixed script.
620
+ const callIndex = state.callSeq++;
621
+ const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions, agentDefinitionKey(agentDef));
622
+ // Store delta key: callIndex alone is NOT run-unique. A nested workflow()
623
+ // call (see workflowFn below) shares this run's SharedStore instance but
624
+ // restarts its own callSeq at 0, so a parent agent and a concurrently
625
+ // running nested-run agent — or two SEQUENTIAL sibling nested runs, whose
626
+ // depth alone would otherwise repeat — can both get callIndex 0 and
627
+ // collide in SharedStore.agentDeltas — whichever commits last
628
+ // steals/overwrites the other's journaled delta (and, via this same
629
+ // deltaKey doubling as the onAgentStart/onAgentEnd/onAgentHistory event
630
+ // id, misattributes one agent's events to the other — see item 2's
631
+ // identity model). Composing the run's own runId (unique per top-level
632
+ // run AND per nested run, see `${runId}-nested${++shared.nestedCallSeq}`
633
+ // below) with callIndex makes the key unique across the whole store.
634
+ const deltaKey = `${runId}:${callIndex}`;
635
+
636
+ // Reserve the agent slot synchronously — atomic with the limit/budget gate
637
+ // above (no await in between) — so a parallel() fan-out can't all observe the
638
+ // same agentCount and overshoot maxAgents. (Token budget stays a soft gate:
639
+ // spent accrues after each agent, matching Claude Code; in-flight agents may
640
+ // push slightly past total, then further agent() calls throw.)
641
+ shared.agentCount++;
642
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
643
+
644
+ // Longest-unchanged-prefix resume: replay a cached result only while the
645
+ // prefix is still intact — this call's index is before the first changed/new
646
+ // call. Once any call misses, it AND everything after it run live (matching
647
+ // Claude Code's contract), so an edited upstream call never leaves stale
648
+ // downstream results served from the journal.
649
+ // Namespaced the same way as SharedStore's deltaKey (deltaKey IS this
650
+ // exact `${runId}:${callIndex}` string) so a nested workflow()'s
651
+ // callIndex-0 can never accidentally replay the parent's callIndex-0
652
+ // entry, or vice versa (see JournalEntry.runId).
653
+ const cached = options.resumeJournal?.get(deltaKey);
654
+ const hashMatches = cached != null && cached.hash === callHash;
655
+ const cachedEmptyOutput = hashMatches && isEmptyTextAgentResult(cached.result, agentOptions.schema);
656
+ if (hashMatches && !cachedEmptyOutput && callIndex < state.firstMiss) {
657
+ options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt, model: displayModel });
658
+ options.onAgentEnd?.({
659
+ id: deltaKey,
660
+ label,
661
+ phase: assignedPhase,
662
+ result: cached.result,
663
+ tokens: 0,
664
+ model: displayModel,
665
+ });
666
+ // Apply this agent's write delta so live agents later in the run see a
667
+ // consistent store. Additive apply preserves parallel-agent writes that
668
+ // came from higher-callIndex agents finishing before this one.
669
+ if (cached.storeDelta) store.applyDelta(cached.storeDelta);
670
+ return cached.result;
671
+ }
672
+ // A genuine miss (no journal entry, or the hash changed) marks where the
673
+ // unchanged prefix ends; this call and every later one then run live.
674
+ if (!hashMatches || cachedEmptyOutput) state.firstMiss = Math.min(state.firstMiss, callIndex);
675
+
676
+ return limiter(async () => {
677
+ const timeout = agentOptions.timeoutMs !== undefined ? agentOptions.timeoutMs : agentTimeoutMs;
678
+ const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
679
+ const maxAttempts = retryAttempts + 1;
680
+
681
+ options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt, model: displayModel });
682
+
683
+ // Optional per-agent worktree isolation (deterministic name -> stable resume keys).
684
+ // Precedence: explicit call-site isolation > agentDef isolation.
685
+ // Note: passing { isolation: undefined } falls through ?? to the def's value — there
686
+ // is no sentinel to suppress a def's isolation at the call site. Remove the agentType
687
+ // or override with a def that has no isolation field if opt-out is needed.
688
+ let worktree: Worktree | undefined;
689
+ const resolvedIsolation = agentOptions.isolation ?? agentDef?.isolation;
690
+ if (resolvedIsolation === "worktree") {
691
+ worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
692
+ if (!worktree.isolated) log(`isolation ignored for "${label}" (${worktree.reason})`);
693
+ }
694
+ const runCwd = worktree?.isolated ? worktree.cwd : undefined;
695
+
696
+ // Captured from the subagent's real session usage; falls back to an
697
+ // estimate when the provider reports no usage (total === 0). Usage is reset
698
+ // per retry attempt so a failed attempt does not double-count the next one.
699
+ let usage: AgentUsage | undefined;
700
+ const recordTokens = (result: unknown): number => {
701
+ const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(prompt);
702
+ if (usage) {
703
+ shared.tokenUsage.input += usage.input;
704
+ shared.tokenUsage.output += usage.output;
705
+ shared.tokenUsage.cost += usage.cost;
706
+ shared.tokenUsage.cacheRead += usage.cacheRead;
707
+ shared.tokenUsage.cacheWrite += usage.cacheWrite;
708
+ }
709
+ shared.tokenUsage.total += tokens;
710
+ shared.spent += tokens;
711
+ return tokens;
712
+ };
713
+
714
+ try {
715
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
716
+ usage = undefined;
717
+ const externalSignal = options.signal;
718
+ let onExternalAbort: (() => void) | undefined;
719
+ let onRunFatal: (() => void) | undefined;
720
+ try {
721
+ throwIfAborted();
722
+ // This agent's own fan-out already breached maxAgents while this
723
+ // call sat queued behind the limiter; bail before spending on the
724
+ // real API call instead of draining the whole reserved queue.
725
+ if (batch?.cancelled) throw agentLimitError();
726
+
727
+ // Per-attempt abort: on timeout we abort THIS agent so its session is
728
+ // disposed and its heavy state (messages, etc.) released, instead of
729
+ // leaving it streaming in the background — retries would otherwise
730
+ // stack live sessions on top of each other (#109). Linked to BOTH the
731
+ // run's external signal (outer abort — pause/stop/Esc) AND
732
+ // shared.runFatalController (this run's fate has been sealed by a
733
+ // sibling's non-recoverable error escaping the top-level script — see
734
+ // SharedRuntime.runFatalController) so an in-flight sibling actually
735
+ // winds down instead of running to completion on a doomed run. Both
736
+ // links are torn down per attempt in finally so listeners don't accrue.
737
+ const agentController = new AbortController();
738
+ if (isAborted()) {
739
+ agentController.abort();
740
+ } else {
741
+ if (externalSignal) {
742
+ onExternalAbort = () => agentController.abort();
743
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
744
+ }
745
+ onRunFatal = () => agentController.abort();
746
+ shared.runFatalController.signal.addEventListener("abort", onRunFatal, { once: true });
747
+ }
748
+ const runPromise = agentRunner.run(prompt, {
749
+ label,
750
+ // Identifiable name for persisted sessions (persistAgentSessions).
751
+ sessionName: `workflow:${runId} ${label}`,
752
+ schema: agentOptions.schema,
753
+ signal: agentController.signal,
754
+ instructions: buildAgentInstructions(assignedPhase, agentOptions, agentDef, resolvedIsolation),
755
+ model: modelSpec,
756
+ tier: agentOptions.tier,
757
+ modelRegistry: options.modelRegistry,
758
+ toolNames: agentDef?.tools,
759
+ disallowedToolNames: agentDef?.disallowedTools,
760
+ // Per-agent store tools track this agent's writes by the
761
+ // run-unique deltaKey so the delta can be journaled and replayed
762
+ // correctly on resume, even when a nested workflow() run shares
763
+ // this store concurrently with the parent run.
764
+ systemTools: createAgentStoreTools(store, deltaKey),
765
+ cwd: runCwd,
766
+ onModelResolved: (id: string) => {
767
+ displayModel = id;
768
+ },
769
+ onModelFallback: (spec: string) => {
770
+ // Make the silent degrade visible in /workflows, not just console.
771
+ log(`${label}: model "${spec}" unavailable — using the session default`);
772
+ },
773
+ onUsage: (u: AgentUsage) => {
774
+ usage = u;
775
+ },
776
+ onHistory: (history: AgentHistoryEntry[]) => {
777
+ options.onAgentHistory?.({ id: deltaKey, label, phase: assignedPhase, history });
778
+ },
779
+ });
780
+ // After a timeout the run() promise still settles later, rejecting with
781
+ // "aborted" once agentController fires; the race has already resolved,
782
+ // so swallow that to avoid an unhandled rejection.
783
+ runPromise.catch(() => {});
784
+ const result = await withTimeout(runPromise, timeout, label, () => agentController.abort());
785
+
786
+ throwIfAborted();
787
+ if (isEmptyTextAgentResult(result, agentOptions.schema)) {
788
+ throw new WorkflowError("Subagent produced no assistant output", WorkflowErrorCode.AGENT_EMPTY_OUTPUT, {
789
+ recoverable: true,
790
+ agentLabel: label,
791
+ });
792
+ }
793
+
794
+ const tokens = recordTokens(result);
795
+ options.onAgentJournal?.({
796
+ index: callIndex,
797
+ runId,
798
+ hash: callHash,
799
+ result,
800
+ storeDelta: store.commitDelta(deltaKey),
801
+ });
802
+ options.onAgentEnd?.({
803
+ id: deltaKey,
804
+ label,
805
+ phase: assignedPhase,
806
+ result,
807
+ tokens,
808
+ tokenUsage: usage,
809
+ worktree: runCwd,
810
+ model: displayModel,
811
+ });
812
+ return result;
813
+ } catch (error) {
814
+ if (isAborted()) throw error;
815
+
816
+ const workflowError = wrapError(error, { agentLabel: label });
817
+ logger.error(`agent ${label} attempt ${attempt}/${maxAttempts} failed: ${workflowError.message}`);
818
+ const tokens = recordTokens(null);
819
+ // This attempt's store writes must not survive it — a failed
820
+ // attempt shares this call's deltaKey with every other attempt
821
+ // (retried or not), so without rolling back here its writes would
822
+ // stay live in the store (visible to concurrently-running sibling
823
+ // agents) and merge into whatever a later, successful attempt
824
+ // commits — corrupting both the live run's state and the delta
825
+ // that resume replay reconstructs from. Unconditional: this
826
+ // covers the about-to-retry case AND the exhausted/non-recoverable
827
+ // case, since neither leaves behind a call that "produced" a
828
+ // result this attempt's writes should be attributed to.
829
+ store.discardDelta(deltaKey);
830
+
831
+ if (workflowError.recoverable && attempt < maxAttempts) {
832
+ log(
833
+ `agent "${label}" attempt ${attempt}/${maxAttempts} failed: ${workflowError.code} ${workflowError.message}; retrying`,
834
+ );
835
+ // This attempt's spend already accrued into shared.spent/tokenUsage
836
+ // above (recordTokens) — but it will never reach onAgentEnd (only
837
+ // the final attempt does), so report it on the dedicated channel
838
+ // instead (see WorkflowRunOptions.onRetrySpend).
839
+ options.onRetrySpend?.(tokens);
840
+ continue;
841
+ }
842
+
843
+ options.onAgentEnd?.({
844
+ id: deltaKey,
845
+ label,
846
+ phase: assignedPhase,
847
+ result: null,
848
+ tokens,
849
+ tokenUsage: usage,
850
+ worktree: runCwd,
851
+ model: displayModel,
852
+ error: workflowError.message,
853
+ errorCode: workflowError.code,
854
+ recoverable: workflowError.recoverable,
855
+ });
856
+
857
+ if (workflowError.recoverable) {
858
+ log(
859
+ `agent "${label}" exhausted ${maxAttempts} attempt${maxAttempts === 1 ? "" : "s"}: ${workflowError.code} ${workflowError.message}`,
860
+ );
861
+ return null;
862
+ }
863
+ throw workflowError;
864
+ } finally {
865
+ // Drop this attempt's abort listeners so they don't accrue one entry
866
+ // per attempt on the run's signal / runFatalController for the whole
867
+ // run (#109 hygiene).
868
+ if (onExternalAbort) externalSignal?.removeEventListener("abort", onExternalAbort);
869
+ if (onRunFatal) shared.runFatalController.signal.removeEventListener("abort", onRunFatal);
870
+ }
871
+ }
872
+ return null;
873
+ } finally {
874
+ // Always tear down the worktree, even on timeout/abort.
875
+ if (worktree?.isolated) await removeWorktree(worktree);
876
+ }
877
+ });
878
+ };
879
+
880
+ const parallel = async (thunks: Array<() => Promise<unknown>>) => {
881
+ throwIfAborted();
882
+ if (!Array.isArray(thunks)) throw new TypeError("parallel() expects an array of functions");
883
+ if (thunks.some((thunk) => typeof thunk !== "function")) {
884
+ throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
885
+ }
886
+ // Batch-scoped cancellation: agent() calls made (directly or transitively)
887
+ // from these thunks see this store via fanoutScope.getStore(). A breach in
888
+ // THIS fan-out flips `cancelled` so its own still-queued agents bail, without
889
+ // touching a sibling fan-out running concurrently or an enclosing one.
890
+ const batch = { cancelled: false };
891
+ return fanoutScope.run(batch, () =>
892
+ Promise.all(
893
+ thunks.map(async (thunk, index) => {
894
+ try {
895
+ return await thunk();
896
+ } catch (error) {
897
+ if (isAborted()) throw error;
898
+ const workflowError = wrapError(error);
899
+ // Non-recoverable failures (token budget / agent limit exhausted) must
900
+ // halt the whole run, exactly like a directly-awaited agent() — not be
901
+ // swallowed into a null in the result array.
902
+ if (!workflowError.recoverable) {
903
+ // Only a breached agent cap cancels the rest of this batch; the
904
+ // token budget stays a soft gate by design (in-flight agents may
905
+ // finish past it), and other non-recoverable errors don't imply
906
+ // the rest of the batch is doomed.
907
+ if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
908
+ throw workflowError;
909
+ }
910
+ log(`parallel[${index}] failed: ${workflowError.message}`);
911
+ return null;
912
+ }
913
+ }),
914
+ ),
915
+ );
916
+ };
917
+
918
+ const pipeline = async (
919
+ items: unknown[],
920
+ ...stages: Array<(prev: unknown, original: unknown, index: number) => unknown>
921
+ ) => {
922
+ throwIfAborted();
923
+ if (!Array.isArray(items)) throw new TypeError("pipeline() expects an array as the first argument");
924
+ if (stages.some((stage) => typeof stage !== "function")) {
925
+ throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
926
+ }
927
+ // Batch-scoped cancellation — see parallel() for the rationale.
928
+ const batch = { cancelled: false };
929
+ return fanoutScope.run(batch, () =>
930
+ Promise.all(
931
+ items.map(async (item, index) => {
932
+ let value: unknown = item;
933
+ for (const stage of stages) {
934
+ try {
935
+ throwIfAborted();
936
+ value = await stage(value, item, index);
937
+ throwIfAborted();
938
+ } catch (error) {
939
+ if (isAborted()) throw error;
940
+ const workflowError = wrapError(error);
941
+ // Non-recoverable failures halt the whole run (see parallel()).
942
+ if (!workflowError.recoverable) {
943
+ if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
944
+ throw workflowError;
945
+ }
946
+ log(`pipeline[${index}] failed: ${workflowError.message}`);
947
+ return null;
948
+ }
949
+ }
950
+ return value;
951
+ }),
952
+ ),
953
+ );
954
+ };
955
+
956
+ // Nested workflow(): run a saved workflow (or a raw script) inline, sharing this
957
+ // run's limiter/counters/budget so the global caps hold. One level deep only.
958
+ const workflowFn = async (nameOrScript: string, childArgs?: unknown) => {
959
+ throwIfAborted();
960
+ if (shared.depth >= 1) {
961
+ throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
962
+ recoverable: false,
963
+ });
964
+ }
965
+ const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
966
+ const childScript = resolved ?? String(nameOrScript);
967
+ const workflowName = String(nameOrScript);
968
+ options.onRuntimeEvent?.({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
969
+ shared.depth++;
970
+ try {
971
+ // Propagate the resumeJournal into the child frame ONLY while the
972
+ // parent's own longest-unchanged-prefix is still intact at the moment
973
+ // of this workflow() call (state.firstMiss === Infinity, i.e. every
974
+ // parent agent()/checkpoint() call BEFORE this one was a cache hit).
975
+ // This is namespacing-safe (see JournalEntry.runId) but namespacing
976
+ // alone is NOT sufficient: SharedStore content itself is not part of
977
+ // any call's hash, so a cached child result was computed against
978
+ // whatever store state the UPSTREAM parent calls had written at the
979
+ // time it originally ran live. If an upstream parent call misses
980
+ // (edited script) and re-runs live, it may write different store
981
+ // values than it did originally — a child cached under the OLD store
982
+ // state would then be replaying a result that's stale with respect to
983
+ // the NEW live state, even though the child's own hash still matches.
984
+ // The prefix contract already treats "this call sits after a miss" as
985
+ // "must run live" for calls within one frame; a nested workflow() is
986
+ // no exception; once anything upstream in the parent has missed, cut
987
+ // the child off from the journal entirely so it runs fully live.
988
+ const prefixIntact = state.firstMiss === Number.POSITIVE_INFINITY;
989
+ const child = await runWorkflow(childScript, {
990
+ ...options,
991
+ args: childArgs,
992
+ sharedRuntime: shared,
993
+ // Propagate the parent's store so nested agents share the same key-value space.
994
+ sharedStore: store,
995
+ resumeJournal: prefixIntact ? options.resumeJournal : undefined,
996
+ resumeFromRunId: undefined,
997
+ // shared.nestedCallSeq, not shared.depth — see its doc comment: depth
998
+ // returns to 0 between sequential sibling calls, which would otherwise
999
+ // mint the same child runId (and hence colliding deltaKeys/event ids)
1000
+ // for two different children.
1001
+ runId: `${runId}-nested${++shared.nestedCallSeq}`,
1002
+ persistLogs: false,
1003
+ });
1004
+ return child.result;
1005
+ } finally {
1006
+ shared.depth--;
1007
+ options.onRuntimeEvent?.({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
1008
+ }
1009
+ };
1010
+
1011
+ // ── Quality-pattern stdlib: reusable, deterministic helpers built purely on
1012
+ // agent()/parallel() (so callSeq ordering stays stable and resume keeps working).
1013
+ // Injected as globals so workflow scripts compose them directly. ──
1014
+
1015
+ const VERIFY_SCHEMA = {
1016
+ type: "object",
1017
+ properties: { real: { type: "boolean" }, reason: { type: "string" } },
1018
+ required: ["real"],
1019
+ };
1020
+ const verify = async (
1021
+ item: unknown,
1022
+ opts: { reviewers?: number; threshold?: number; lens?: string | string[] } = {},
1023
+ ) => {
1024
+ options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "verify" });
1025
+ const reviewers = Math.max(1, opts.reviewers ?? 2);
1026
+ const threshold = opts.threshold ?? 0.5;
1027
+ const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
1028
+ const claim = typeof item === "string" ? item : JSON.stringify(item);
1029
+ const votes = (
1030
+ await parallel(
1031
+ Array.from(
1032
+ { length: reviewers },
1033
+ (_v, i) => () =>
1034
+ agent(
1035
+ `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}`,
1036
+ { label: `verify ${i + 1}`, schema: VERIFY_SCHEMA },
1037
+ ),
1038
+ ),
1039
+ )
1040
+ ).filter(Boolean) as Array<{ real?: boolean; reason?: string }>;
1041
+ const realCount = votes.filter((v) => v?.real).length;
1042
+ const verdict = {
1043
+ real: votes.length > 0 && realCount / votes.length >= threshold,
1044
+ realCount,
1045
+ total: votes.length,
1046
+ votes,
1047
+ };
1048
+ options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "verify" });
1049
+ return verdict;
1050
+ };
1051
+
1052
+ const JUDGE_SCHEMA = {
1053
+ type: "object",
1054
+ properties: { score: { type: "number" }, reason: { type: "string" } },
1055
+ required: ["score"],
1056
+ };
1057
+ const judgePanel = async (attempts: unknown[], opts: { judges?: number; rubric?: string } = {}) => {
1058
+ options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "judgePanel" });
1059
+ const judges = Math.max(1, opts.judges ?? 3);
1060
+ const rubric = opts.rubric ?? "overall quality and correctness";
1061
+ const scored = (
1062
+ await parallel(
1063
+ (Array.isArray(attempts) ? attempts : []).map((att, idx) => async () => {
1064
+ const text = typeof att === "string" ? att : JSON.stringify(att);
1065
+ const js = (
1066
+ await parallel(
1067
+ Array.from(
1068
+ { length: judges },
1069
+ (_v, j) => () =>
1070
+ agent(
1071
+ `Score this candidate from 0 to 1 on: ${rubric}. Reply with the score.\n\nCandidate:\n${text}`,
1072
+ {
1073
+ label: `judge ${idx + 1}.${j + 1}`,
1074
+ schema: JUDGE_SCHEMA,
1075
+ },
1076
+ ),
1077
+ ),
1078
+ )
1079
+ ).filter(Boolean) as Array<{ score?: number }>;
1080
+ const score = js.length ? js.reduce((s, v) => s + (Number(v?.score) || 0), 0) / js.length : 0;
1081
+ return { index: idx, attempt: att, score, judgments: js };
1082
+ }),
1083
+ )
1084
+ ).filter(Boolean) as Array<{ index: number; attempt: unknown; score: number; judgments: unknown[] }>;
1085
+ // Highest mean score; stable tie-break by input index.
1086
+ let best = scored[0];
1087
+ for (const s of scored) if (s.score > best.score || (s.score === best.score && s.index < best.index)) best = s;
1088
+ options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
1089
+ return best;
1090
+ };
1091
+
1092
+ const loopUntilDry = async (opts: {
1093
+ round: (roundIndex: number) => Promise<unknown[]> | unknown[];
1094
+ key?: (item: unknown) => string;
1095
+ consecutiveEmpty?: number;
1096
+ maxRounds?: number;
1097
+ }) => {
1098
+ if (!opts || typeof opts.round !== "function")
1099
+ throw new TypeError("loopUntilDry requires { round: (i) => items[] }");
1100
+ const key = opts.key ?? ((x: unknown) => JSON.stringify(x));
1101
+ const consecutiveEmpty = Math.max(1, opts.consecutiveEmpty ?? 2);
1102
+ const maxRounds = opts.maxRounds ?? 50;
1103
+ const seen = new Set<string>();
1104
+ const all: unknown[] = [];
1105
+ let dry = 0;
1106
+ for (let r = 0; r < maxRounds && dry < consecutiveEmpty; r++) {
1107
+ let items: unknown[];
1108
+ try {
1109
+ items = (await opts.round(r)) ?? [];
1110
+ } catch (error) {
1111
+ // Budget / agent-limit exhaustion: return the partial result, don't abort.
1112
+ const code = (error as { code?: string })?.code;
1113
+ if (code === WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED || code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) break;
1114
+ throw error;
1115
+ }
1116
+ const fresh = (Array.isArray(items) ? items : []).filter((x) => x != null && !seen.has(key(x)));
1117
+ if (!fresh.length) {
1118
+ dry++;
1119
+ continue;
1120
+ }
1121
+ dry = 0;
1122
+ for (const x of fresh) {
1123
+ seen.add(key(x));
1124
+ all.push(x);
1125
+ }
1126
+ }
1127
+ return all;
1128
+ };
1129
+
1130
+ const COMPLETENESS_SCHEMA = {
1131
+ type: "object",
1132
+ properties: { complete: { type: "boolean" }, missing: { type: "array", items: { type: "string" } } },
1133
+ required: ["complete"],
1134
+ };
1135
+ const completenessCheck = async (taskArgs: unknown, results: unknown) => {
1136
+ options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "completenessCheck" });
1137
+ const verdict = await agent(
1138
+ `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)}`,
1139
+ { label: "completeness critic", schema: COMPLETENESS_SCHEMA },
1140
+ );
1141
+ options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "completenessCheck" });
1142
+ return verdict;
1143
+ };
1144
+
1145
+ // Thin bounded-retry / validation-gate combinators. Sugar over the for-loop +
1146
+ // agent() pattern, but each attempt is a real agent() call so it auto-journals
1147
+ // under a stable callSeq (resume-safe). No backoff: there is no timer in the vm
1148
+ // and a delay has no resume value. NOTE: attempt N+1's call hash depends on N's
1149
+ // live result, so a retry/gate chain cache-miss-cascades on resume (correct).
1150
+ const retry = async (
1151
+ thunk: (attempt: number) => Promise<unknown> | unknown,
1152
+ opts: { attempts?: number; until?: (r: unknown) => boolean } = {},
1153
+ ) => {
1154
+ const attempts = Math.max(1, opts.attempts ?? 3);
1155
+ let last: unknown;
1156
+ for (let i = 0; i < attempts; i++) {
1157
+ last = await thunk(i);
1158
+ const accepted = !opts.until || opts.until(last);
1159
+ options.onRuntimeEvent?.({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
1160
+ if (accepted) return last;
1161
+ }
1162
+ return last; // attempts exhausted — return the last result (caller inspects it)
1163
+ };
1164
+ const gate = async (
1165
+ thunk: (feedback: string | undefined, attempt: number) => Promise<unknown> | unknown,
1166
+ validator: (r: unknown) => Promise<{ ok: boolean; feedback?: string }> | { ok: boolean; feedback?: string },
1167
+ opts: { attempts?: number } = {},
1168
+ ) => {
1169
+ const attempts = Math.max(1, opts.attempts ?? 3);
1170
+ let feedback: string | undefined;
1171
+ let last: unknown;
1172
+ for (let i = 0; i < attempts; i++) {
1173
+ last = await thunk(feedback, i);
1174
+ const verdict = await validator(last);
1175
+ const accepted = Boolean(verdict?.ok);
1176
+ options.onRuntimeEvent?.({ type: "control-attempt", helper: "gate", attempt: i + 1, accepted });
1177
+ if (accepted) return { ok: true, value: last, attempts: i + 1 };
1178
+ feedback = verdict?.feedback; // fed into the next attempt
1179
+ }
1180
+ return { ok: false, value: last, attempts };
1181
+ };
1182
+
1183
+ // Deterministic, journaled, replayable human checkpoint. Spends no tokens, so it
1184
+ // is gated on the agent counter + abort (not budget). On resume the human's reply
1185
+ // replays by callIndex exactly like a cached agent() — the genuine edge over CC,
1186
+ // whose steering is in-session only. Headless (no UI threaded in): takes the
1187
+ // declared default and journals THAT, so a detached/background run never hangs.
1188
+ const checkpoint = async (promptText: string, checkpointOptions: CheckpointOptions = {}) => {
1189
+ throwIfAborted();
1190
+ if (typeof promptText !== "string") throw new TypeError("checkpoint(promptText, options?) needs a prompt string");
1191
+ if (shared.agentCount >= maxAgents) {
1192
+ throw agentLimitError();
1193
+ }
1194
+ const callIndex = state.callSeq++;
1195
+ const callHash = hashCheckpoint(promptText, checkpointOptions);
1196
+ // Namespaced by runId like agent()'s deltaKey — see JournalEntry.runId.
1197
+ const journalKey = `${runId}:${callIndex}`;
1198
+ const cached = options.resumeJournal?.get(journalKey);
1199
+ if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
1200
+ shared.agentCount++;
1201
+ return cached.result; // replay the journaled human reply
1202
+ }
1203
+ if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
1204
+ shared.agentCount++;
1205
+
1206
+ let reply: unknown;
1207
+ if (options.confirm) {
1208
+ reply = await options.confirm(promptText, checkpointOptions);
1209
+ } else if (checkpointOptions.headless === "abort") {
1210
+ throw new WorkflowError(
1211
+ `checkpoint "${promptText}" needs human input but none is available (headless run)`,
1212
+ WorkflowErrorCode.WORKFLOW_ABORTED,
1213
+ { recoverable: false },
1214
+ );
1215
+ } else {
1216
+ reply = checkpointOptions.default ?? true;
1217
+ }
1218
+ throwIfAborted();
1219
+ options.onAgentJournal?.({ index: callIndex, runId, hash: callHash, result: reply });
1220
+ return reply;
1221
+ };
1222
+
1223
+ const runtimeImplementations = {
1224
+ agent,
1225
+ parallel,
1226
+ pipeline,
1227
+ workflow: workflowFn,
1228
+ verify,
1229
+ judgePanel,
1230
+ loopUntilDry,
1231
+ completenessCheck,
1232
+ retry,
1233
+ gate,
1234
+ checkpoint,
1235
+ log,
1236
+ phase,
1237
+ args: options.args,
1238
+ cwd: options.cwd ?? process.cwd(),
1239
+ process: Object.freeze({ cwd: () => options.cwd ?? process.cwd() }),
1240
+ budget,
1241
+ console: {
1242
+ log,
1243
+ info: log,
1244
+ warn: (m: unknown) => log(`[warn] ${String(m)}`),
1245
+ error: (m: unknown) => log(`[error] ${String(m)}`),
1246
+ },
1247
+ } satisfies WorkflowRuntimeImplementations;
1248
+ const { globals: projectGlobals, diagnostics: bindingDiagnostics } =
1249
+ WORKFLOW_CAPABILITY_CONTRACT.assembleRuntimeBindings(runtimeImplementations);
1250
+ for (const diagnostic of bindingDiagnostics) logger.warn(diagnostic.message);
1251
+ const context = vm.createContext({
1252
+ ...projectGlobals,
1253
+ // Object/Array/JSON/Math/Date/Promise/Set/Map/etc. come from the vm realm
1254
+ // itself — we deliberately do NOT inject host built-ins, whose .constructor
1255
+ // would be the host Function (a determinism-guard bypass). Math/Date are
1256
+ // neutered in-realm by DETERMINISM_PRELUDE below.
1257
+ });
1258
+
1259
+ const wrapped = `${DETERMINISM_PRELUDE}\n(async () => {\n${body}\n})()`;
1260
+ try {
1261
+ const result = await new vm.Script(wrapped, { filename: `${meta.name || "workflow"}.js` }).runInContext(context);
1262
+
1263
+ // Persist logs
1264
+ const logFile = logger.persist();
1265
+ if (logFile) {
1266
+ log(`Logs persisted to ${logFile}`);
1267
+ }
1268
+
1269
+ // Emit final token usage
1270
+ options.onTokenUsage?.(shared.tokenUsage);
1271
+
1272
+ return {
1273
+ meta,
1274
+ result: result as T,
1275
+ logs: state.logs,
1276
+ phases: state.phases,
1277
+ agentCount: shared.agentCount,
1278
+ durationMs: Date.now() - started,
1279
+ runId,
1280
+ tokenUsage: shared.tokenUsage,
1281
+ };
1282
+ } catch (error) {
1283
+ // This error just escaped THIS frame's own vm script execution completely
1284
+ // uncaught. For the top-level frame that means nothing anywhere in the
1285
+ // whole call chain (this script, any enclosing try/catch around a nested
1286
+ // workflow()/parallel()/agent()) caught it — the run's fate is genuinely
1287
+ // sealed now (see SharedRuntime.runFatalController). Sealing it here, not
1288
+ // inside agent()/parallel(), is what preserves parallel()'s "a thrown
1289
+ // thunk resolves to null without failing the others" contract and a
1290
+ // script's own try/catch around agent()/workflow(): both those cases are
1291
+ // swallowed well before an error would ever reach this catch. A NESTED
1292
+ // frame reaching here does NOT seal anything — the parent script may still
1293
+ // catch workflow()'s rejection and continue, so only isTopLevelRun acts.
1294
+ // Idempotent: if this is already an intentional pause/stop (options.signal
1295
+ // aborted) or a second escape after the fatal signal already fired,
1296
+ // aborting an already-aborted controller is a no-op.
1297
+ //
1298
+ // This also fires on a PROVIDER_USAGE_LIMIT escape (a quota/rate-limit
1299
+ // hit), not just a genuine bug — that error is non-recoverable too (see
1300
+ // errors.ts), so it escapes exactly like any other run-fatal error and
1301
+ // seals the same way. Deliberate tradeoff: any sibling still in flight
1302
+ // when the quota was hit gets aborted rather than allowed to finish and
1303
+ // journal — this stops burning an already-exhausted budget right now, at
1304
+ // the cost of that sibling's work being thrown away and re-run live when
1305
+ // the paused run resumes (it was never journaled, so it isn't cached).
1306
+ if (isTopLevelRun) shared.runFatalController.abort();
1307
+ throw error;
1308
+ } finally {
1309
+ // Only the top-level frame drains/disposes (see isTopLevelRun) — a nested
1310
+ // workflow()'s in-flight agents are still tracked in this SAME shared set
1311
+ // and get drained once, here, when the whole run finishes.
1312
+ if (isTopLevelRun) {
1313
+ // Wait out every agent() call spawned anywhere in this run — including
1314
+ // ones the script never awaited — before the store goes away. Without
1315
+ // this, a forgotten `await agent(...)` could keep mutating store/journal
1316
+ // state after the run is marked complete/failed and torn down. Loop
1317
+ // (not a single Promise.allSettled) because draining can itself let a
1318
+ // still-running call schedule further work that adds to the set.
1319
+ //
1320
+ // Caveat: this can block indefinitely. A run-fatal abort (see the catch
1321
+ // above) aborts the AbortSignal passed to each in-flight agent, but that
1322
+ // is cooperative — an agent runner that ignores its signal (or one still
1323
+ // waiting out a real subagent process that won't die) never settles on
1324
+ // its own. Combined with agentTimeoutMs: null (no hard timeout, the
1325
+ // default), a single hung, signal-ignoring, un-awaited agent() call can
1326
+ // wedge this drain — and therefore the whole run's completion — forever.
1327
+ // Configure a finite agentTimeoutMs (run- or per-agent-level) for any
1328
+ // workflow where this is a real risk; there is no drain-side timeout.
1329
+ if (shared.inFlight.size > 0) {
1330
+ log(`waiting for ${shared.inFlight.size} outstanding agent() call(s) to settle before this run completes`);
1331
+ }
1332
+ while (shared.inFlight.size > 0) {
1333
+ await Promise.allSettled(Array.from(shared.inFlight));
1334
+ }
1335
+ store.dispose();
1336
+ }
1337
+ }
1338
+ }
1339
+
1340
+ export function parseWorkflowScript(script: string): { meta: WorkflowMeta; body: string } {
1341
+ if (DETERMINISM_BLOCKLIST.test(script)) {
1342
+ throw new WorkflowError(
1343
+ "Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable",
1344
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1345
+ { recoverable: false },
1346
+ );
1347
+ }
1348
+
1349
+ const ast = parse(script, {
1350
+ ecmaVersion: "latest",
1351
+ sourceType: "module",
1352
+ allowAwaitOutsideFunction: true,
1353
+ allowReturnOutsideFunction: true,
1354
+ ranges: false,
1355
+ }) as AnyNode;
1356
+
1357
+ const first = ast.body?.[0] as AnyNode | undefined;
1358
+ if (first?.type !== "ExportNamedDeclaration") {
1359
+ throw new WorkflowError(
1360
+ "`export const meta = { name, description, phases }` must be the first statement in the script",
1361
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1362
+ { recoverable: false },
1363
+ );
1364
+ }
1365
+
1366
+ const declaration = first.declaration as AnyNode | null;
1367
+ if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") {
1368
+ throw new WorkflowError(
1369
+ "meta export must be `export const meta = ...`",
1370
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1371
+ {
1372
+ recoverable: false,
1373
+ },
1374
+ );
1375
+ }
1376
+ if (declaration.declarations.length !== 1) {
1377
+ throw new WorkflowError("meta export must declare only `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
1378
+ recoverable: false,
1379
+ });
1380
+ }
1381
+
1382
+ const declarator = declaration.declarations[0] as AnyNode;
1383
+ if (declarator.id?.type !== "Identifier" || declarator.id.name !== "meta") {
1384
+ throw new WorkflowError("meta export must declare `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
1385
+ recoverable: false,
1386
+ });
1387
+ }
1388
+ if (!declarator.init)
1389
+ throw new WorkflowError("meta must have a literal value", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
1390
+ recoverable: false,
1391
+ });
1392
+
1393
+ const meta = evaluateLiteral(declarator.init, "meta");
1394
+ validateMeta(meta);
1395
+
1396
+ return {
1397
+ meta,
1398
+ body: script.slice(0, first.start) + script.slice(first.end),
1399
+ };
1400
+ }
1401
+
1402
+ function evaluateLiteral(node: AnyNode, path: string): unknown {
1403
+ switch (node.type) {
1404
+ case "ObjectExpression": {
1405
+ const out: Record<string, unknown> = {};
1406
+ for (const prop of node.properties as AnyNode[]) {
1407
+ if (prop.type === "SpreadElement") throw new Error(`spread not allowed in ${path}`);
1408
+ if (prop.type !== "Property") throw new Error(`only plain properties allowed in ${path}`);
1409
+ if (prop.computed) throw new Error(`computed keys not allowed in ${path}`);
1410
+ if (prop.kind !== "init" || prop.method) throw new Error(`methods/accessors not allowed in ${path}`);
1411
+ const key = propertyKey(prop.key as AnyNode, path);
1412
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
1413
+ throw new Error(`reserved key name not allowed in ${path}: ${key}`);
1414
+ }
1415
+ out[key] = evaluateLiteral(prop.value as AnyNode, `${path}.${key}`);
1416
+ }
1417
+ return out;
1418
+ }
1419
+ case "ArrayExpression":
1420
+ return (node.elements as Array<AnyNode | null>).map((element, index) => {
1421
+ if (!element) throw new Error(`sparse arrays not allowed in ${path}`);
1422
+ if (element.type === "SpreadElement") throw new Error(`spread not allowed in ${path}`);
1423
+ return evaluateLiteral(element, `${path}[${index}]`);
1424
+ });
1425
+ case "Literal":
1426
+ return node.value;
1427
+ case "TemplateLiteral":
1428
+ if (node.expressions.length > 0) throw new Error(`template interpolation not allowed in ${path}`);
1429
+ return node.quasis.map((quasi: AnyNode) => quasi.value.cooked ?? quasi.value.raw).join("");
1430
+ case "UnaryExpression":
1431
+ if (node.operator === "-" && node.argument?.type === "Literal" && typeof node.argument.value === "number") {
1432
+ return -node.argument.value;
1433
+ }
1434
+ throw new Error(`only negative-number unary allowed in ${path}`);
1435
+ default:
1436
+ throw new Error(`non-literal node type in ${path}: ${node.type}`);
1437
+ }
1438
+ }
1439
+
1440
+ function propertyKey(node: AnyNode, path: string): string {
1441
+ if (node.type === "Identifier") return node.name;
1442
+ if (node.type === "Literal" && (typeof node.value === "string" || typeof node.value === "number"))
1443
+ return String(node.value);
1444
+ throw new Error(`unsupported key type in ${path}: ${node.type}`);
1445
+ }
1446
+
1447
+ function validateMeta(meta: unknown): asserts meta is WorkflowMeta {
1448
+ if (!meta || typeof meta !== "object") throw new Error("meta must be an object");
1449
+ const value = meta as WorkflowMeta;
1450
+ if (typeof value.name !== "string" || !value.name.trim()) throw new Error("meta.name must be a non-empty string");
1451
+ if (typeof value.description !== "string" || !value.description.trim())
1452
+ throw new Error("meta.description must be a non-empty string");
1453
+ if (value.model !== undefined && typeof value.model !== "string") throw new Error("meta.model must be a string");
1454
+ if (value.phases !== undefined) {
1455
+ if (!Array.isArray(value.phases)) throw new Error("meta.phases must be an array");
1456
+ for (const phase of value.phases) {
1457
+ if (!phase || typeof phase !== "object" || typeof (phase as WorkflowMetaPhase).title !== "string") {
1458
+ throw new Error("each meta phase must have a title string");
1459
+ }
1460
+ }
1461
+ }
1462
+ }
1463
+
1464
+ function createLimiter(limit: number) {
1465
+ let active = 0;
1466
+ const queue: Array<() => void> = [];
1467
+ const next = () => {
1468
+ active--;
1469
+ queue.shift()?.();
1470
+ };
1471
+ return async <T>(fn: () => Promise<T>): Promise<T> => {
1472
+ if (active >= limit) await new Promise<void>((resolve) => queue.push(resolve));
1473
+ active++;
1474
+ try {
1475
+ return await fn();
1476
+ } finally {
1477
+ next();
1478
+ }
1479
+ };
1480
+ }
1481
+
1482
+ function defaultAgentLabel(phase: string | undefined, index: number): string {
1483
+ return phase ? `${phase} agent ${index}` : `agent ${index}`;
1484
+ }
1485
+
1486
+ /**
1487
+ * Stable identity hash for a checkpoint() call — a cache miss on resume when
1488
+ * anything that could change its outcome changes. Must cover every
1489
+ * CheckpointOptions field that participates in the outcome, not just
1490
+ * promptText/kind/choices:
1491
+ * - `default` and `headless` decide the reply in the headless (no `confirm`
1492
+ * threaded in) path — a script edited to change either must not resume
1493
+ * with the OLD default/behavior's stale journaled reply.
1494
+ * - `timeoutMs` bounds the interactive prompt; a host `confirm` may itself
1495
+ * fall back to `default` when the human doesn't answer in time, so it can
1496
+ * also affect the outcome and is included for the same reason.
1497
+ * NOTE: widening this hash is a one-time invalidation of any checkpoint
1498
+ * answers already persisted under the old (narrower) hash — on the first
1499
+ * resume after upgrading, those checkpoints will cache-miss and re-prompt (or
1500
+ * re-apply the default) once, live. That's intentional: a silently-stale
1501
+ * cached decision from before the identity surface was fixed is worse than a
1502
+ * one-time re-ask.
1503
+ */
1504
+ function hashCheckpoint(promptText: string, options: CheckpointOptions): string {
1505
+ const identity = JSON.stringify({
1506
+ promptText,
1507
+ kind: options.kind ?? "confirm",
1508
+ choices: options.choices ?? null,
1509
+ default: options.default ?? null,
1510
+ headless: options.headless ?? "default",
1511
+ timeoutMs: options.timeoutMs ?? null,
1512
+ });
1513
+ return createHash("sha256").update(identity).digest("hex");
1514
+ }
1515
+
1516
+ function hashAgentCall(
1517
+ prompt: string,
1518
+ model: string | undefined,
1519
+ phase: string | undefined,
1520
+ options: AgentOptions,
1521
+ agentDefKey: string | null,
1522
+ ): string {
1523
+ const identity = JSON.stringify({
1524
+ prompt,
1525
+ model: model ?? null,
1526
+ tier: options.tier ?? null,
1527
+ phase: phase ?? null,
1528
+ agentType: options.agentType ?? null,
1529
+ // Resolved definition (tools/model/prompt) so editing an agent .md invalidates
1530
+ // this call's cached result on a later resume.
1531
+ agentDef: agentDefKey,
1532
+ schema: options.schema ?? null,
1533
+ });
1534
+ return createHash("sha256").update(identity).digest("hex");
1535
+ }
1536
+
1537
+ function buildAgentInstructions(
1538
+ phase: string | undefined,
1539
+ options: AgentOptions,
1540
+ def: AgentDefinition | undefined,
1541
+ resolvedIsolation?: "worktree",
1542
+ ): string | undefined {
1543
+ const lines: string[] = [];
1544
+ // A resolved agentType binds a real role prompt (the definition body). Only
1545
+ // fall back to the prose hint when the agentType named no known definition.
1546
+ if (def?.prompt) lines.push(def.prompt);
1547
+ else if (options.agentType) lines.push(`Act as workflow subagent type: ${options.agentType}`);
1548
+ if (phase) lines.push(`Workflow phase: ${phase}`);
1549
+ // Use resolvedIsolation so the annotation fires whether isolation came from
1550
+ // the call site or from the agentDef's isolation field.
1551
+ if (resolvedIsolation) lines.push(`Requested isolation: ${resolvedIsolation}`);
1552
+ // Note: options.model is applied for real via the session, not injected as prose.
1553
+ return lines.length ? lines.join("\n\n") : undefined;
1554
+ }
1555
+
1556
+ function isEmptyTextAgentResult(result: unknown, schema: TSchema | undefined): boolean {
1557
+ return schema === undefined && typeof result === "string" && result.trim().length === 0;
1558
+ }
1559
+
1560
+ function estimateTokens(value: unknown): number {
1561
+ return Math.ceil(JSON.stringify(value ?? "").length / 4);
1562
+ }
1563
+
1564
+ function normalizeConcurrency(value: unknown): number {
1565
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1;
1566
+ return Math.min(MAX_CONCURRENCY, Math.floor(value));
1567
+ }
1568
+
1569
+ function normalizeAgentRetries(value: unknown): number {
1570
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
1571
+ return Math.min(MAX_AGENT_RETRIES, Math.floor(value));
1572
+ }
1573
+
1574
+ /**
1575
+ * Run a promise with a timeout.
1576
+ *
1577
+ * `onTimeout` fires when the deadline hits, BEFORE the timeout rejection wins the
1578
+ * race — the caller uses it to abort the underlying work (e.g. the subagent
1579
+ * session) so it can release its resources instead of streaming on in the
1580
+ * background with the whole session graph (messages, etc.) retained (#109). The
1581
+ * losing promise still settles later; the caller must swallow its rejection.
1582
+ */
1583
+ async function withTimeout<T>(
1584
+ promise: Promise<T>,
1585
+ ms: number | null,
1586
+ label: string,
1587
+ onTimeout?: () => void,
1588
+ ): Promise<T> {
1589
+ if (ms === null) return promise;
1590
+
1591
+ let timeoutId: NodeJS.Timeout | undefined;
1592
+
1593
+ const timeoutPromise = new Promise<never>((_, reject) => {
1594
+ timeoutId = setTimeout(() => {
1595
+ try {
1596
+ onTimeout?.();
1597
+ } catch {
1598
+ // Best-effort cleanup; never let it mask the timeout error.
1599
+ }
1600
+ reject(
1601
+ new WorkflowError(
1602
+ `Agent "${label}" timed out after ${ms}ms; raise or omit timeoutMs/agentTimeoutMs to allow longer runs`,
1603
+ WorkflowErrorCode.AGENT_TIMEOUT,
1604
+ { recoverable: true },
1605
+ ),
1606
+ );
1607
+ }, ms);
1608
+ });
1609
+
1610
+ try {
1611
+ return await Promise.race([promise, timeoutPromise]);
1612
+ } finally {
1613
+ if (timeoutId) clearTimeout(timeoutId);
1614
+ }
1615
+ }