@foldrun/core 0.3.0 → 0.5.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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -8,10 +8,39 @@
8
8
  // results. That is what lets the same function run somewhere the vault, the
9
9
  // library and the account do not exist.
10
10
  import fs from "node:fs";
11
+ import os from "node:os";
11
12
  import path from "node:path";
12
13
  import { query } from "@anthropic-ai/claude-agent-sdk";
13
14
  import { spawn } from "node:child_process";
14
- import { checkPaths, checkBash, isFilesystemTool } from "./confine.js";
15
+ import { checkPaths, checkBash, isFilesystemTool, linkWorkspace, workspaceLinkEnv, resolveAgentPath, isWithin } from "./confine.js";
16
+ import { DELEGATE_TOOLS, subagentGuard, toAgentDefinitions } from "./subagents.js";
17
+ import { hostSafeEnv } from "./host-env.js";
18
+ import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
19
+ import { CallCounter, limitKeysFor } from "./limits.js";
20
+ import { classifyCall, podAt, readPodEvents } from "./browser-pod.js";
21
+ /** How often the stop flag is read while a step runs. */
22
+ export const STOP_POLL_MS = 2000;
23
+ /** The least a `verify:` gets when the step used up most of its `timeout:` —
24
+ * capped by the timeout itself, so the step and its check together never
25
+ * run past timeout + this. Under the isolated path's backstop (+60 s). */
26
+ export const VERIFY_FLOOR_MS = 30_000;
27
+ /** How long a `verify:` may run: what is left of the step's `timeout:`,
28
+ * with a floor. Null when the flow set no timeout — `timeout:` is the only
29
+ * clock there is, and a stop still ends the check. */
30
+ export function verifyBudgetMs(timeoutSec, elapsedMs) {
31
+ if (!timeoutSec || timeoutSec <= 0)
32
+ return null;
33
+ const total = timeoutSec * 1000;
34
+ return Math.max(total - elapsedMs, Math.min(VERIFY_FLOOR_MS, total));
35
+ }
36
+ /** After an interrupt is asked for, how long the model loop gets to wind
37
+ * down on its own before the query is aborted outright. */
38
+ export const INTERRUPT_GRACE_MS = 10_000;
39
+ /** The tag on every event a failed check writes — `verify:`, `output: json`,
40
+ * a schema — headline and detail alike. The runner's no-retry rule for
41
+ * outward steps reads it (actedThenFailedCheck); reading the wording of the
42
+ * last error missed a check whose detail line came after its headline. */
43
+ const CHECK = { check: true };
15
44
  /** Conservative per-token rates for a model nothing else can price — an
16
45
  * unknown id on a gateway with no catalogue. Opus-class, so a ceiling
17
46
  * still means something. Only ever reached when `knownPrice` below does
@@ -72,24 +101,89 @@ export function stepCeiling(budgetUsd, spentUsd, launching) {
72
101
  return null;
73
102
  return Math.max(0, budgetUsd - spentUsd) / Math.max(1, launching);
74
103
  }
75
- export async function executeStep(opts) {
104
+ /**
105
+ * The ceiling a step actually runs under, and which line set it.
106
+ *
107
+ * Two caps can apply to one step: the flow's `budget:` gives it an equal
108
+ * share of what the run has left; the agent's own `budget:` is the most that
109
+ * agent may spend in one run, less what its steps in this run have cost or
110
+ * may still cost, shared across `sharing` — this step and the agent's steps
111
+ * launched with it that have not started yet. Without the share, twenty
112
+ * fan-out copies of one agent each got the whole remainder, because none of
113
+ * them has recorded a cent when the next one starts.
114
+ * The tighter one wins, and the note says which, because "over budget" with
115
+ * no line to go and raise is a message that sends someone to the wrong file.
116
+ */
117
+ export function stepCeilingFor(flowShareUsd, agentBudgetUsd, agentSpentUsd, agent, sharing = 1) {
118
+ const flow = { ceilingUsd: flowShareUsd, note: "budget: in the flow file" };
119
+ if (!agentBudgetUsd || agentBudgetUsd <= 0)
120
+ return flow;
121
+ const left = Math.max(0, agentBudgetUsd - agentSpentUsd) / Math.max(1, sharing);
122
+ if (flowShareUsd !== null && flowShareUsd <= left)
123
+ return flow;
124
+ return { ceilingUsd: left, note: `budget: on the ${agent} agent` };
125
+ }
126
+ export async function executeStep(opts,
127
+ /** The model loop. Injected so the clock and the stop can be tested
128
+ * without a model; production passes nothing and gets the SDK. */
129
+ runQuery = query) {
130
+ // `workspace/…` for the shell, the scripts and a shell `verify:` — the
131
+ // file tools expand the prefix themselves. Held for the step, verify
132
+ // included, and gone before anyone reads the step's files back
133
+ // (confine.ts#linkWorkspace says why it is not permanent).
134
+ fs.mkdirSync(opts.agentDir, { recursive: true });
135
+ const link = linkWorkspace(opts.agentDir, opts.workspaceRoot);
136
+ if (link.note)
137
+ opts.emit("info", `workspace/: ${link.note}`);
138
+ try {
139
+ // And a Node program started through it is still its own main module
140
+ // (confine.ts#workspaceLinkEnv) — for the model's Bash, which inherits
141
+ // the SDK's env; the shell verify: and the scripts get it where they
142
+ // spawn.
143
+ return await executeStepInner({ ...opts, env: workspaceLinkEnv(opts.env, opts.agentDir) }, runQuery);
144
+ }
145
+ finally {
146
+ link.release();
147
+ }
148
+ }
149
+ async function executeStepInner(opts, runQuery) {
76
150
  const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
77
151
  let status = "running";
78
152
  let costUsd = null;
79
153
  let usage = null;
154
+ let stopReason = null;
80
155
  const texts = [];
81
156
  fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
82
157
  fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
83
- const q = query({
158
+ // The one handle that can end the loop from outside: the timeout and a
159
+ // stop both ask the query to interrupt, and abort it outright if it has
160
+ // not wound down within the grace period.
161
+ const abort = new AbortController();
162
+ const subagents = opts.subagents?.length ? opts.subagents : null;
163
+ const guardSubagent = subagents ? subagentGuard(subagents) : null;
164
+ // A sub-agent's calls reach both the hook (which sees its id AND name) and
165
+ // canUseTool (which sees only the id). Remembered here so every refusal —
166
+ // its own list, a path outside the workspace, a shell command — names the
167
+ // agent that tried, not just the step.
168
+ const subagentNames = new Map();
169
+ // `limits:` — this step's counts. A new step, a retry of one, or a
170
+ // fan-out instance each start from zero.
171
+ const counter = new CallCounter(opts.limits);
172
+ const q = runQuery({
84
173
  prompt: opts.prompt,
85
174
  options: {
86
175
  cwd: agentDir,
176
+ abortController: abort,
87
177
  model: opts.model,
88
178
  // Omitted, not passed as undefined-with-a-default: an unset effort
89
179
  // should mean "whatever this model does normally", which is not a
90
180
  // level we can name — it moves as models ship.
91
181
  ...(opts.effort ? { effort: opts.effort } : {}),
92
182
  systemPrompt: opts.systemPrompt,
183
+ ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
184
+ // Sub-agents: the SDK's own delegation. Every definition carries an
185
+ // explicit tool list (an absent one inherits all of ours).
186
+ ...(subagents ? { agents: toAgentDefinitions(subagents) } : {}),
93
187
  // Restrict the toolset itself, not just approval: an agent that
94
188
  // declares no tools gets none, instead of seeing the full Claude
95
189
  // Code toolset and burning turns on denied calls.
@@ -116,13 +210,96 @@ export async function executeStep(opts) {
116
210
  autoAllowBashIfSandboxed: false,
117
211
  failIfUnavailable: false,
118
212
  },
119
- env: opts.env,
213
+ // With sub-agents, the SDK would also offer its built-in ones
214
+ // (general-purpose has every tool the session has). Only the declared
215
+ // ones may run; the guard in the hook below refuses the rest as well.
216
+ env: subagents ? { ...(opts.env ?? process.env), CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : opts.env,
120
217
  mcpServers: opts.mcpServers,
121
- canUseTool: async (toolName, input) => {
218
+ // canUseTool is only asked when the SDK wants permission, and it never
219
+ // asks for a read inside the cwd — so `Read workspace/storage/x` went
220
+ // to `<agentDir>/workspace/storage/x` unchecked and unrewritten. On
221
+ // 2026-09-24 that read a stray copy a broken write had left there,
222
+ // with no error. A PreToolUse hook runs on every call: expand the
223
+ // virtual prefix (and refuse an escape) here for every filesystem tool,
224
+ // and leave the rest of the decision to canUseTool.
225
+ hooks: {
226
+ PreToolUse: [{
227
+ hooks: [async (hookInput) => {
228
+ if (hookInput.hook_event_name !== "PreToolUse")
229
+ return {};
230
+ // A sub-agent's calls come through here too, tagged with its
231
+ // name; anything outside its own list — or from a sub-agent this
232
+ // step did not declare — is refused before the shared checks
233
+ // below (which apply to it unchanged). So is a delegation from
234
+ // the main thread to anything but a declared sub-agent.
235
+ const agentId = "agent_id" in hookInput && hookInput.agent_id ? String(hookInput.agent_id) : undefined;
236
+ const agentType = agentId ? hookInput.agent_type : undefined;
237
+ const via = agentId ? (agentType ?? "subagent") : undefined;
238
+ if (agentId && via)
239
+ subagentNames.set(agentId, via);
240
+ if (guardSubagent) {
241
+ const why = guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: (hookInput.tool_input ?? {}) });
242
+ if (why) {
243
+ emit("error", why, { subagent: via });
244
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: why } };
245
+ }
246
+ }
247
+ const verdict = isFilesystemTool(hookInput.tool_name)
248
+ ? checkPaths(hookInput.tool_name, hookInput.tool_input, { agentDir, workspaceRoot, libraryRoot })
249
+ : null;
250
+ if (verdict && !verdict.ok) {
251
+ emit("error", verdict.reason, via ? { subagent: via } : undefined);
252
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: verdict.reason } };
253
+ }
254
+ // `limits:` — last, so a call another check refused counts
255
+ // toward nothing. Every call passes here, the step's own and its
256
+ // sub-agents', so one count covers both. A call past a limit is
257
+ // refused before it runs: the tool, and any paid API behind it,
258
+ // never sees it.
259
+ if (counter.active) {
260
+ const refusal = counter.take(limitKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners));
261
+ if (refusal) {
262
+ emit("info", `${refusal.message} (${hookInput.tool_name} refused)`, via ? { subagent: via } : undefined);
263
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal.message } };
264
+ }
265
+ }
266
+ if (!verdict?.updatedInput)
267
+ return {};
268
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: verdict.updatedInput } };
269
+ }],
270
+ }],
271
+ // A message a person sent into the running step reaches the model
272
+ // after its next tool call, as context — never as a change to what it
273
+ // may do. A model that is only writing, calling no tools, hears it at
274
+ // the next call it makes.
275
+ ...(opts.inbox
276
+ ? {
277
+ PostToolUse: [{
278
+ hooks: [async (hookInput) => {
279
+ if (hookInput.hook_event_name !== "PostToolUse")
280
+ return {};
281
+ // A sub-agent's call: the message is for the step's own
282
+ // model, and draining it here would hand it to a context
283
+ // that ends with the delegation — and lose it.
284
+ if ("agent_id" in hookInput && hookInput.agent_id)
285
+ return {};
286
+ const context = await opts.inbox().catch(() => null);
287
+ return context ? { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: context } } : {};
288
+ }],
289
+ }],
290
+ }
291
+ : {}),
292
+ },
293
+ canUseTool: async (toolName, input, options) => {
294
+ const via = options?.agentID ? (subagentNames.get(options.agentID) ?? "subagent") : undefined;
122
295
  // The toolset was already narrowed to what the agent declared, so
123
296
  // anything outside it is a denial with a reason the model can act on.
124
297
  const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
298
+ // A call the hook counted and this refuses never ran: give it back.
299
+ const refund = () => { if (counter.active)
300
+ counter.refund(limitKeysFor(toolName, input, opts.toolOwners)); };
125
301
  if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
302
+ refund();
126
303
  return {
127
304
  behavior: "deny",
128
305
  message: `Tool ${toolName} is not enabled for this agent.`,
@@ -134,14 +311,46 @@ export async function executeStep(opts) {
134
311
  ? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
135
312
  : { ok: true };
136
313
  if (!verdict.ok) {
137
- emit("error", verdict.reason);
314
+ refund();
315
+ emit("error", verdict.reason, via ? { subagent: via } : undefined);
138
316
  return { behavior: "deny", message: verdict.reason };
139
317
  }
140
318
  return { behavior: "allow", updatedInput: verdict.updatedInput ?? input };
141
319
  },
142
320
  },
143
321
  });
144
- const deadline = opts.timeoutSec ? Date.now() + opts.timeoutSec * 1000 : null;
322
+ // Why the loop was ended from outside, if it was. The deadline used to be
323
+ // checked only when a message arrived, so a step waiting on one long tool
324
+ // call — a crawl, a build — sailed past its timeout: with no message there
325
+ // was nothing to check it against. The backstop timer fires regardless,
326
+ // and a stop is read on a clock rather than between groups.
327
+ let ended = null;
328
+ const startedAt = Date.now();
329
+ const timers = [];
330
+ const endWith = (why) => {
331
+ if (ended)
332
+ return;
333
+ ended = why;
334
+ void q.interrupt().catch(() => { });
335
+ timers.push(setTimeout(() => abort.abort(), INTERRUPT_GRACE_MS));
336
+ };
337
+ if (opts.timeoutSec)
338
+ timers.push(setTimeout(() => endWith("timeout"), opts.timeoutSec * 1000));
339
+ if (opts.stopRequested) {
340
+ timers.push(setInterval(() => {
341
+ try {
342
+ if (opts.stopRequested())
343
+ endWith("stopped");
344
+ }
345
+ catch {
346
+ // an unreadable record is not a stop
347
+ }
348
+ }, STOP_POLL_MS));
349
+ }
350
+ // The clocks stay referenced on purpose: a step hanging in a tool call may
351
+ // be the only thing keeping the loop alive, and an unreferenced timer
352
+ // would let the process exit around it instead of ending it. They are all
353
+ // cleared when the step ends, so nothing outlives the step.
145
354
  // The hard cap. Spend is counted as each assistant turn arrives — its
146
355
  // usage is in the message — so the step can stop at the ceiling rather
147
356
  // than learn afterwards that it crossed it. Cache traffic is counted as
@@ -149,73 +358,232 @@ export async function executeStep(opts) {
149
358
  // that is the honest direction to be wrong in.
150
359
  const ceiling = typeof opts.budgetUsd === "number" && opts.budgetUsd > 0 ? opts.budgetUsd : null;
151
360
  let spentUsd = 0;
361
+ // Every turn's usage, by the model message's id. The SDK can emit one
362
+ // turn as several assistant messages that repeat the same usage, so a
363
+ // turn is counted once — and the sum is the step's cost whenever the
364
+ // closing `result` never arrives: a timeout or a stop ends the loop before
365
+ // it, and a step that made 193 model calls was recorded as $0 (lawyer-desk,
366
+ // 2026-09-25 09:00).
367
+ // Each with the rate it is priced at: a sub-agent with its own `model:`
368
+ // runs its turns on that model, and pricing them at the parent's put a
369
+ // max sub-agent under a fast parent at a fifth of its cost — against the
370
+ // ceiling and on the bill.
371
+ const turns = new Map();
372
+ let anonymousTurns = 0;
373
+ const specByName = new Map((subagents ?? []).map((s) => [s.name, s]));
374
+ const priceOf = (message) => {
375
+ const parent = message.parent_tool_use_id;
376
+ if (!parent)
377
+ return opts.price ?? null;
378
+ const declared = specByName.get(delegations.get(parent) ?? "")?.model;
379
+ if (declared === undefined || declared === "inherit" || declared === opts.model)
380
+ return opts.price ?? null;
381
+ const model = message.message?.model;
382
+ return knownPrice(typeof model === "string" ? model : undefined) ?? knownPrice(declared);
383
+ };
384
+ const turnsTotal = () => [...turns.values()].reduce((sum, t) => sum + priceTurn(t.u, t.price), 0);
385
+ const mixedModels = () => [...turns.values()].some((t) => t.price !== (opts.price ?? null));
152
386
  // Open tool calls, by the provider's id, so the result can be paired with
153
387
  // its call and the trace can say how long each tool ran.
154
388
  const openCalls = new Map();
155
- for await (const message of q) {
156
- if (deadline && Date.now() > deadline) {
157
- emit("error", `step exceeded its ${opts.timeoutSec}s timeout`);
158
- status = "failed";
159
- break;
389
+ // Delegations in flight: the parent's Agent call id → the sub-agent's name.
390
+ // The SDK tags every message a sub-agent produces with that call's id
391
+ // (parent_tool_use_id), which is how its tool calls are labelled here.
392
+ const delegations = new Map();
393
+ const subagentOf = (message) => {
394
+ const parent = message.parent_tool_use_id;
395
+ return parent ? (delegations.get(parent) ?? "subagent") : undefined;
396
+ };
397
+ // Slim browsing (browser-pod.ts): every call the step makes, read or
398
+ // write, in the order the model asked for them — the step's own and its
399
+ // sub-agents'. Classified when asked for, not when answered: a call in
400
+ // flight when the pod goes is counted as made. And the web tool's pod log,
401
+ // read after every tool result.
402
+ const pod = opts.browserPod ?? null;
403
+ const ledger = new Map();
404
+ const podState = { reconnects: 0, reconnected: 0 };
405
+ let podOffset = 0;
406
+ const readPod = (callId) => {
407
+ if (!pod)
408
+ return;
409
+ let text;
410
+ try {
411
+ text = fs.readFileSync(pod.events, "utf8");
160
412
  }
161
- if (message.type === "assistant") {
162
- const u = message.message.usage;
163
- if (ceiling && u) {
164
- spentUsd += priceTurn(u, opts.price ?? null);
165
- if (spentUsd >= ceiling) {
166
- emit("error", `over budget — this step reached $${spentUsd.toFixed(4)} of its $${ceiling.toFixed(4)} ceiling mid-turn and was stopped (budget: in the flow file)`);
167
- status = "failed";
168
- break;
169
- }
413
+ catch {
414
+ return; // nothing written yet
415
+ }
416
+ const { events, next } = readPodEvents(text, podOffset);
417
+ podOffset = next;
418
+ let closedAgain = false;
419
+ for (const [i, e] of events.entries()) {
420
+ if (e.kind === "reconnect") {
421
+ podState.reconnects += 1;
422
+ // A reconnect that reached the pod, and then the call dropped again
423
+ // at once (the web tool's next line is "lost"), did not get
424
+ // through: the pod answered while it shut down. Live, 2026-10-01,
425
+ // "reconnected (try 1 of 3)" was followed by "lost after 1
426
+ // reconnect try" and the step read "1 got through".
427
+ closedAgain = !!e.ok && events[i + 1]?.kind === "lost";
428
+ if (closedAgain)
429
+ podState.closedAgain = (podState.closedAgain ?? 0) + 1;
430
+ else if (e.ok)
431
+ podState.reconnected += 1;
432
+ // `at` is when the try happened: these lines reach the run when the
433
+ // call returns, so the run's own stamps on them are all the same.
434
+ emit("info", `browser pod: ${e.ok ? "reconnected" : "reconnect failed"} (try ${e.attempt ?? "?"} of ${e.of ?? "?"}${podAt(e.at)})${!e.ok && e.error ? ` — ${e.error}` : ""}${closedAgain ? ", but the pod closed again" : ""}`);
170
435
  }
171
- for (const block of message.message.content) {
172
- if (block.type === "text" && block.text.trim()) {
173
- texts.push(block.text);
174
- emit("text", block.text);
436
+ else if (!podState.lost) {
437
+ // A call that never reached the pod changed nothing, whatever its
438
+ // arguments asked for.
439
+ if (e.ran === false)
440
+ ledger.delete(callId);
441
+ podState.lost = { cause: e.kind, detail: (e.kind === "needs-full" ? e.why : e.error) ?? "connection closed" };
442
+ emit("info", e.kind === "needs-full"
443
+ ? `browser pod: this call needs a browser in the step, which the slim image has not (${podState.lost.detail}) — stopping the step`
444
+ : closedAgain
445
+ ? `browser pod: lost — the call dropped again after the reconnect (${podState.lost.detail}${podAt(e.at)}) — stopping the step`
446
+ : `browser pod: lost after ${podState.reconnects} reconnect tr${podState.reconnects === 1 ? "y" : "ies"} (${podState.lost.detail}${podAt(e.at)}) — stopping the step`);
447
+ endWith("pod-lost");
448
+ }
449
+ }
450
+ };
451
+ try {
452
+ for await (const message of q) {
453
+ if (ended)
454
+ break;
455
+ if (message.type === "assistant") {
456
+ const m = message.message;
457
+ const u = m.usage;
458
+ if (u)
459
+ turns.set(m.id ?? `turn-${anonymousTurns++}`, { u, price: priceOf(message) });
460
+ if (ceiling && u) {
461
+ spentUsd = turnsTotal();
462
+ if (spentUsd >= ceiling) {
463
+ emit("error", `over budget — this step reached $${spentUsd.toFixed(4)} of its $${ceiling.toFixed(4)} ceiling mid-turn and was stopped (${opts.budgetNote ?? "budget: in the flow file"})`);
464
+ status = "failed";
465
+ break;
466
+ }
175
467
  }
176
- else if (block.type === "tool_use") {
177
- openCalls.set(block.id, { name: block.name, at: Date.now() });
178
- emit("tool", block.name, { call: block.id });
468
+ const via = subagentOf(message);
469
+ for (const block of message.message.content) {
470
+ if (block.type === "text" && block.text.trim()) {
471
+ // A sub-agent's own words are its business; the parent's reply is
472
+ // the step's result. (The SDK forwards only tool blocks by default.)
473
+ if (via)
474
+ continue;
475
+ texts.push(block.text);
476
+ emit("text", block.text);
477
+ }
478
+ else if (block.type === "tool_use") {
479
+ if (!via && DELEGATE_TOOLS.includes(block.name)) {
480
+ const input = block.input;
481
+ const to = String(input?.subagent_type ?? "subagent");
482
+ delegations.set(block.id, to);
483
+ // One line a person can read: who got the job, and what it was.
484
+ const job = String(input?.description ?? input?.prompt ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
485
+ emit("info", `delegating to ${to}${job ? `: ${job}` : ""}`, { subagent: to });
486
+ }
487
+ openCalls.set(block.id, { name: block.name, at: Date.now(), ...(via ? { subagent: via } : {}) });
488
+ if (pod)
489
+ ledger.set(block.id, classifyCall(block.name, block.input, pod));
490
+ emit("tool", block.name, { call: block.id, ...(via ? { subagent: via } : {}) });
491
+ }
179
492
  }
180
493
  }
181
- }
182
- else if (message.type === "user") {
183
- // Tool results ride back as user turns. The completion event closes
184
- // the span the call opened; the runner's journal keeps both.
185
- const content = message.message.content;
186
- if (Array.isArray(content)) {
187
- for (const block of content) {
188
- if (block.type !== "tool_result")
189
- continue;
190
- const open = openCalls.get(block.tool_use_id);
191
- if (!open)
192
- continue;
193
- openCalls.delete(block.tool_use_id);
194
- emit("tool", open.name, {
195
- call: block.tool_use_id,
196
- ms: Date.now() - open.at,
197
- ...(block.is_error ? { err: true } : {}),
198
- });
494
+ else if (message.type === "user") {
495
+ // Tool results ride back as user turns. The completion event closes
496
+ // the span the call opened; the runner's journal keeps both.
497
+ const content = message.message.content;
498
+ if (Array.isArray(content)) {
499
+ for (const block of content) {
500
+ if (block.type !== "tool_result")
501
+ continue;
502
+ const open = openCalls.get(block.tool_use_id);
503
+ if (!open)
504
+ continue;
505
+ openCalls.delete(block.tool_use_id);
506
+ emit("tool", open.name, {
507
+ call: block.tool_use_id,
508
+ ms: Date.now() - open.at,
509
+ ...(block.is_error ? { err: true } : {}),
510
+ ...(open.subagent ? { subagent: open.subagent } : {}),
511
+ });
512
+ readPod(block.tool_use_id);
513
+ }
199
514
  }
200
515
  }
201
- }
202
- else if (message.type === "result") {
203
- status = message.subtype === "success" ? "completed" : "failed";
204
- costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
205
- if ("usage" in message && message.usage) {
206
- const u = message.usage;
207
- usage = {
208
- // Cache traffic is input the provider still bills (at its own
209
- // rates); folding it into the input count over-approximates for
210
- // gateways with cheaper cache reads, which errs on the honest side.
211
- inputTokens: (u.input_tokens ?? 0) +
212
- (u.cache_creation_input_tokens ?? 0) +
213
- (u.cache_read_input_tokens ?? 0),
214
- outputTokens: u.output_tokens ?? 0,
215
- };
516
+ else if (message.type === "result") {
517
+ status = message.subtype === "success" ? "completed" : "failed";
518
+ if (message.subtype === "error_max_turns") {
519
+ emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
520
+ }
521
+ costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
522
+ stopReason = "stop_reason" in message && typeof message.stop_reason === "string" ? message.stop_reason : null;
523
+ if ("usage" in message && message.usage) {
524
+ const u = message.usage;
525
+ usage = {
526
+ // Cache traffic is input the provider still bills (at its own
527
+ // rates); folding it into the input count over-approximates for
528
+ // gateways with cheaper cache reads, which errs on the honest side.
529
+ inputTokens: (u.input_tokens ?? 0) +
530
+ (u.cache_creation_input_tokens ?? 0) +
531
+ (u.cache_read_input_tokens ?? 0),
532
+ outputTokens: u.output_tokens ?? 0,
533
+ };
534
+ }
216
535
  }
217
536
  }
218
537
  }
538
+ catch (err) {
539
+ // An abort we asked for is not an error of the step's; anything else is.
540
+ if (!ended)
541
+ throw err;
542
+ }
543
+ finally {
544
+ for (const t of timers)
545
+ clearTimeout(t);
546
+ }
547
+ // What the step used of each limit, on the trace whenever any is set —
548
+ // so "it stopped searching at 40" is read off the run, not guessed.
549
+ const limitLine = counter.summary();
550
+ if (limitLine)
551
+ emit("info", limitLine);
552
+ // No result message — a timeout, a stop, a budget stop, a stream that
553
+ // died — means no total from the SDK. The turns it did take were billed by
554
+ // the provider all the same, so the step's cost is their sum.
555
+ if (costUsd === null && turns.size) {
556
+ let input = 0;
557
+ let output = 0;
558
+ let priced = 0;
559
+ for (const { u: t, price } of turns.values()) {
560
+ input += (t.input_tokens ?? 0) + (t.cache_creation_input_tokens ?? 0) + (t.cache_read_input_tokens ?? 0);
561
+ output += t.output_tokens ?? 0;
562
+ priced += priceTurn(t, price);
563
+ }
564
+ costUsd = priced;
565
+ usage ??= { inputTokens: input, outputTokens: output };
566
+ emit("info", `cost from ${turns.size} model turn${turns.size === 1 ? "" : "s"}: $${priced.toFixed(4)} — the step ended before the model's closing total`);
567
+ }
568
+ if (ended === "timeout") {
569
+ emit("error", `timed out after ${opts.timeoutSec}s (timeout: in the flow file) — the step was stopped with the files it wrote so far kept`);
570
+ status = "failed";
571
+ }
572
+ else if (ended === "stopped") {
573
+ emit("error", "stopped by a person mid-step");
574
+ status = "failed";
575
+ }
576
+ else if (ended === "pod-lost") {
577
+ // The runner says what happens next — a re-run on the full image, or a
578
+ // failure naming what was written. Not an error of the step's own.
579
+ status = "failed";
580
+ }
581
+ if (pod) {
582
+ readPod("");
583
+ if (podState.lost) {
584
+ podState.writes = [...ledger.values()].flatMap((k) => (k.write ? [k.what] : []));
585
+ }
586
+ }
219
587
  // The SDK ends a healthy run with a `result` message. A stream that just
220
588
  // stops — subprocess OOM-killed, crashed, or torn down — used to fall
221
589
  // through as "completed", which reported a run that produced nothing as a
@@ -240,28 +608,88 @@ export async function executeStep(opts) {
240
608
  if (extracted.ok) {
241
609
  data = extracted.value;
242
610
  emit("info", `output: json — ${describeJson(data)}`);
611
+ // The declared shape, checked here beside the extraction: a value
612
+ // that parses but is not what the next step was promised is the same
613
+ // failure as no value, and the message names the field.
614
+ const errors = opts.schema !== undefined && looksLikeSchema(opts.schema) ? validateSchema(data, opts.schema) : [];
615
+ if (errors.length) {
616
+ emit("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`, CHECK);
617
+ status = "failed";
618
+ }
243
619
  }
244
620
  else {
245
- emit("error", `output: json — ${extracted.reason}`);
621
+ emit("error", `output: json — ${extracted.reason}`, CHECK);
246
622
  status = "failed";
247
623
  }
248
624
  }
249
625
  // "Done" should mean a check passed, not that the model stopped talking.
626
+ // The loop's clocks are cleared by now, so the check gets its own: what is
627
+ // left of the step's `timeout:` (never less than VERIFY_FLOOR_MS of it),
628
+ // and a stop read on the same clock the loop used. No `timeout:`, no clock:
629
+ // it is the only one there is, here as in the loop. A `verify:` that hangs
630
+ // — a build waiting on a lock, a judge that never answers — used to hold
631
+ // the step, and the run, with nothing to end it.
250
632
  if (status === "completed" && opts.verify) {
251
- const verdict = await checkVerify(agentDir, opts.verify, {
252
- env: opts.verifyEnv ?? {},
253
- result,
254
- conclusion,
255
- data,
256
- modelEnv: opts.env,
257
- });
258
- emit(verdict.ok ? "info" : "error", `verify \`${opts.verify}\` → ${verdict.headline}`);
259
- if (verdict.detail.trim())
260
- emit(verdict.ok ? "info" : "error", verdict.detail.slice(0, 1000));
261
- if (!verdict.ok)
633
+ const verifyMs = verifyBudgetMs(opts.timeoutSec, Date.now() - startedAt);
634
+ const halt = new AbortController();
635
+ let cut = null;
636
+ const clocks = [];
637
+ if (verifyMs !== null) {
638
+ clocks.push(setTimeout(() => {
639
+ cut ??= "timeout";
640
+ halt.abort();
641
+ }, verifyMs));
642
+ }
643
+ if (opts.stopRequested) {
644
+ clocks.push(setInterval(() => {
645
+ try {
646
+ if (opts.stopRequested()) {
647
+ cut ??= "stopped";
648
+ halt.abort();
649
+ }
650
+ }
651
+ catch {
652
+ // an unreadable record is not a stop
653
+ }
654
+ }, STOP_POLL_MS));
655
+ }
656
+ let verdict;
657
+ try {
658
+ verdict = await checkVerify(agentDir, opts.verify, {
659
+ env: opts.verifyEnv ?? {},
660
+ result,
661
+ conclusion,
662
+ data,
663
+ modelEnv: opts.env,
664
+ signal: halt.signal,
665
+ });
666
+ }
667
+ finally {
668
+ for (const t of clocks)
669
+ clearTimeout(t);
670
+ }
671
+ if (cut === "timeout") {
672
+ emit("error", `verify \`${opts.verify}\` → timed out after ${Math.round(verifyMs / 1000)}s (what was left of timeout: in the flow file) — it was stopped`, CHECK);
673
+ status = "failed";
674
+ }
675
+ else if (cut === "stopped") {
676
+ emit("error", `verify \`${opts.verify}\` → stopped by a person mid-check`, CHECK);
262
677
  status = "failed";
678
+ }
679
+ else {
680
+ emit(verdict.ok ? "info" : "error", `verify \`${opts.verify}\` → ${verdict.headline}`, verdict.ok ? undefined : CHECK);
681
+ if (verdict.detail.trim())
682
+ emit(verdict.ok ? "info" : "error", verdict.detail.slice(0, 1000), verdict.ok ? undefined : CHECK);
683
+ if (!verdict.ok)
684
+ status = "failed";
685
+ }
263
686
  }
264
- return { status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage };
687
+ return {
688
+ status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage,
689
+ ...(mixedModels() ? { turnsCostUsd: turnsTotal() } : {}),
690
+ stopReason,
691
+ ...(pod ? { browserPod: podState } : {}),
692
+ };
265
693
  }
266
694
  // ------------------------------------------------------------ output: json
267
695
  /**
@@ -319,8 +747,21 @@ const VERIFY_ASSERTION = /^(contains|not-contains|matches|file|judge):\s*([\s\S]
319
747
  export async function checkVerify(agentDir, verify, ctx) {
320
748
  const m = verify.trim().match(VERIFY_ASSERTION);
321
749
  if (!m) {
322
- const { code, out } = await runVerify(agentDir, verify, ctx.env, ctx.data);
323
- return { ok: code === 0, headline: `exit ${code ?? "error"}`, detail: out };
750
+ // The step's final turn, as a file named in FOLDRUN_REPLY_FILE — so one
751
+ // command can check the reply and the artefact together
752
+ // (`grep -q '^READY' "$FOLDRUN_REPLY_FILE" && test -s out.json`). A step
753
+ // has one `verify:`, and a shell check that could not see the reply
754
+ // made authors choose between the two.
755
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-reply-"));
756
+ const replyFile = path.join(dir, "reply.md");
757
+ fs.writeFileSync(replyFile, ctx.conclusion ?? ctx.result ?? "");
758
+ try {
759
+ const { code, out } = await runVerify(agentDir, verify, { ...ctx.env, FOLDRUN_REPLY_FILE: replyFile }, ctx.data, ctx.signal);
760
+ return { ok: code === 0, headline: `exit ${code ?? "error"}`, detail: out };
761
+ }
762
+ finally {
763
+ fs.rmSync(dir, { recursive: true, force: true });
764
+ }
324
765
  }
325
766
  const [, kind, rawValue] = m;
326
767
  const value = rawValue.trim().replace(/^["']|["']$/g, "");
@@ -345,15 +786,20 @@ export async function checkVerify(agentDir, verify, ctx) {
345
786
  }
346
787
  }
347
788
  case "file": {
348
- const target = path.resolve(agentDir, value);
349
- if (!target.startsWith(path.resolve(agentDir) + path.sep)) {
350
- return { ok: false, headline: "path escapes the agent directory", detail: "" };
789
+ // Agent-relative like every path in a flow, or `workspace/…` from the
790
+ // workspace root; confined to the workspace. It was confined to the
791
+ // agent's own folder, so the one place a step's deliverable belongs —
792
+ // `workspace/storage/x`, or `../../storage/x` — was always "escapes".
793
+ const workspaceRoot = path.resolve(agentDir, "..", "..");
794
+ const target = resolveAgentPath(workspaceRoot, agentDir, value);
795
+ if (!isWithin(workspaceRoot, target) || target === workspaceRoot) {
796
+ return { ok: false, headline: "path escapes the workspace", detail: "" };
351
797
  }
352
798
  const ok = fs.existsSync(target) && fs.statSync(target).size > 0;
353
799
  return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
354
800
  }
355
801
  case "judge": {
356
- const verdict = await judgeReply(value, output, ctx.modelEnv ?? {});
802
+ const verdict = await judgeReply(value, output, ctx.modelEnv ?? {}, ctx.signal);
357
803
  const ok = /^\s*PASS\b/i.test(verdict);
358
804
  return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
359
805
  }
@@ -365,8 +811,12 @@ export async function checkVerify(agentDir, verify, ctx) {
365
811
  * Same shape as the eval judge, and answered with one word first so the
366
812
  * verdict is a prefix test rather than a reading.
367
813
  */
368
- async function judgeReply(rubric, output, env) {
814
+ async function judgeReply(rubric, output, env, signal) {
369
815
  const texts = [];
816
+ const abortController = new AbortController();
817
+ if (signal?.aborted)
818
+ abortController.abort();
819
+ signal?.addEventListener("abort", () => abortController.abort(), { once: true });
370
820
  try {
371
821
  const q = query({
372
822
  prompt: `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
@@ -379,6 +829,7 @@ async function judgeReply(rubric, output, env) {
379
829
  settingSources: [],
380
830
  env,
381
831
  maxTurns: 1,
832
+ abortController,
382
833
  },
383
834
  });
384
835
  for await (const message of q) {
@@ -397,15 +848,37 @@ async function judgeReply(rubric, output, env) {
397
848
  }
398
849
  // Verification runs in the agent's directory with its secrets available, so
399
850
  // a check can be as simple as `npm run build` or `test -s outputs/report.md`.
400
- function runVerify(agentDir, command, env, data) {
851
+ function runVerify(agentDir, command, env, data, signal) {
401
852
  return new Promise((resolve) => {
402
- // No clock of the platform's: a verify like `npm run build` takes what
403
- // it takes, and the step's own `timeout:` is the bound if the flow set one.
853
+ // The clock is the caller's (executeStep): what is left of the step's
854
+ // `timeout:`, ended through `signal`, which a stop also fires.
855
+ // The allowlisted host base plus what the caller passed: the step's
856
+ // secrets and identifiers on the in-process path (runner.ts), the
857
+ // container's own environment on the isolated one (run-container.ts).
858
+ // Never process.env whole — see host-env.ts.
404
859
  const child = spawn("bash", ["-lc", command], {
405
860
  cwd: agentDir,
406
- env: { ...process.env, ...env },
861
+ // workspaceLinkEnv: `node workspace/tools/x/check.mjs` must run its
862
+ // check, not pass for having skipped it (confine.ts says how).
863
+ env: workspaceLinkEnv({ ...hostSafeEnv(), ...env }, agentDir),
407
864
  stdio: ["pipe", "pipe", "pipe"],
865
+ // Its own process group, so an abort ends what the shell started
866
+ // (`npm run build` and its children) and not only bash.
867
+ detached: true,
408
868
  });
869
+ const kill = () => {
870
+ try {
871
+ process.kill(-child.pid, "SIGKILL");
872
+ }
873
+ catch {
874
+ child.kill("SIGKILL");
875
+ }
876
+ };
877
+ if (signal?.aborted)
878
+ kill();
879
+ else
880
+ signal?.addEventListener("abort", kill, { once: true });
881
+ child.on("close", () => signal?.removeEventListener("abort", kill));
409
882
  // An `output: json` step's data arrives on stdin, so a check can be
410
883
  // `jq -e '.total > 0'` — arithmetic in a real tool, reading the value
411
884
  // the step actually returned rather than re-parsing its prose.