@foldrun/core 0.4.0 → 0.6.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 (170) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -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/browser-pod.d.ts +148 -0
  9. package/dist/src/browser-pod.js +333 -0
  10. package/dist/src/catalog.js +2 -1
  11. package/dist/src/clock.d.ts +49 -0
  12. package/dist/src/clock.js +121 -0
  13. package/dist/src/completions.js +17 -8
  14. package/dist/src/confine.d.ts +22 -0
  15. package/dist/src/confine.js +218 -3
  16. package/dist/src/container.d.ts +9 -0
  17. package/dist/src/container.js +115 -5
  18. package/dist/src/context-tools.d.ts +1 -1
  19. package/dist/src/deploy.d.ts +33 -0
  20. package/dist/src/deploy.js +145 -6
  21. package/dist/src/egress.d.ts +7 -0
  22. package/dist/src/egress.js +6 -0
  23. package/dist/src/eol.d.ts +2 -0
  24. package/dist/src/eol.js +7 -0
  25. package/dist/src/evals.d.ts +5 -0
  26. package/dist/src/evals.js +9 -2
  27. package/dist/src/flow-lint.d.ts +8 -0
  28. package/dist/src/flow-lint.js +223 -1
  29. package/dist/src/flow-patterns.d.ts +141 -0
  30. package/dist/src/flow-patterns.js +568 -0
  31. package/dist/src/gitrepo.d.ts +18 -0
  32. package/dist/src/gitrepo.js +49 -0
  33. package/dist/src/host-env.js +5 -0
  34. package/dist/src/kinds.js +1 -1
  35. package/dist/src/language.d.ts +17 -0
  36. package/dist/src/language.js +55 -0
  37. package/dist/src/layout.d.ts +69 -0
  38. package/dist/src/layout.js +200 -0
  39. package/dist/src/library.d.ts +9 -0
  40. package/dist/src/library.js +30 -3
  41. package/dist/src/limits.d.ts +84 -0
  42. package/dist/src/limits.js +287 -0
  43. package/dist/src/locale.d.ts +47 -0
  44. package/dist/src/locale.js +177 -0
  45. package/dist/src/mail.d.ts +88 -0
  46. package/dist/src/mail.js +247 -0
  47. package/dist/src/model-credential.d.ts +14 -0
  48. package/dist/src/model-credential.js +58 -0
  49. package/dist/src/model-probe.d.ts +3 -0
  50. package/dist/src/model-probe.js +1 -0
  51. package/dist/src/notify.d.ts +53 -7
  52. package/dist/src/notify.js +194 -126
  53. package/dist/src/openapi.js +5 -3
  54. package/dist/src/operator.d.ts +201 -0
  55. package/dist/src/operator.js +251 -0
  56. package/dist/src/paths.d.ts +28 -0
  57. package/dist/src/paths.js +55 -0
  58. package/dist/src/platform.d.ts +43 -3
  59. package/dist/src/platform.js +9 -1
  60. package/dist/src/provider-health.js +3 -2
  61. package/dist/src/providers.d.ts +481 -0
  62. package/dist/src/providers.js +1105 -6
  63. package/dist/src/refusal.d.ts +83 -0
  64. package/dist/src/refusal.js +281 -0
  65. package/dist/src/run-container.d.ts +149 -13
  66. package/dist/src/run-container.js +424 -43
  67. package/dist/src/runner.d.ts +177 -9
  68. package/dist/src/runner.js +1146 -154
  69. package/dist/src/runtime.d.ts +52 -0
  70. package/dist/src/runtime.js +493 -58
  71. package/dist/src/script-tools.d.ts +17 -2
  72. package/dist/src/script-tools.js +82 -10
  73. package/dist/src/secret-files.d.ts +6 -5
  74. package/dist/src/secret-files.js +24 -11
  75. package/dist/src/secret-health.d.ts +30 -6
  76. package/dist/src/secret-health.js +42 -20
  77. package/dist/src/secrets.d.ts +19 -0
  78. package/dist/src/secrets.js +155 -86
  79. package/dist/src/starter.d.ts +9 -0
  80. package/dist/src/starter.js +92 -11
  81. package/dist/src/step-exec.d.ts +72 -2
  82. package/dist/src/step-exec.js +431 -37
  83. package/dist/src/step-removal.d.ts +15 -0
  84. package/dist/src/step-removal.js +49 -0
  85. package/dist/src/storage.d.ts +23 -4
  86. package/dist/src/storage.js +125 -23
  87. package/dist/src/store.d.ts +222 -37
  88. package/dist/src/store.js +349 -62
  89. package/dist/src/subagents.d.ts +75 -0
  90. package/dist/src/subagents.js +149 -0
  91. package/dist/src/test-mode.d.ts +17 -2
  92. package/dist/src/test-mode.js +53 -13
  93. package/dist/src/tool-names.d.ts +13 -1
  94. package/dist/src/tool-names.js +38 -11
  95. package/dist/src/tool-programs.d.ts +26 -0
  96. package/dist/src/tool-programs.js +172 -0
  97. package/dist/src/tool-test.js +104 -12
  98. package/dist/src/translator.d.ts +8 -2
  99. package/dist/src/translator.js +69 -23
  100. package/dist/src/trigger-log.d.ts +1 -1
  101. package/dist/src/triggers.d.ts +3 -0
  102. package/dist/src/triggers.js +10 -2
  103. package/dist/src/untrusted-fetch.d.ts +11 -0
  104. package/dist/src/untrusted-fetch.js +77 -0
  105. package/dist/src/web-actions.d.ts +91 -0
  106. package/dist/src/web-actions.js +199 -0
  107. package/dist/src/webhook.d.ts +4 -0
  108. package/dist/src/webhook.js +39 -0
  109. package/index.ts +4 -0
  110. package/package.json +3 -3
  111. package/src/account.ts +149 -0
  112. package/src/api-tools.ts +32 -4
  113. package/src/browser-pod.ts +350 -0
  114. package/src/catalog.ts +2 -1
  115. package/src/clock.ts +149 -0
  116. package/src/completions.ts +17 -8
  117. package/src/confine.ts +218 -3
  118. package/src/container.ts +112 -5
  119. package/src/context-tools.ts +1 -1
  120. package/src/deploy.ts +137 -6
  121. package/src/egress.ts +13 -1
  122. package/src/eol.ts +7 -0
  123. package/src/evals.ts +14 -2
  124. package/src/flow-lint.ts +253 -1
  125. package/src/flow-patterns.ts +597 -0
  126. package/src/gitrepo.ts +48 -0
  127. package/src/host-env.ts +5 -0
  128. package/src/kinds.ts +1 -1
  129. package/src/language.ts +62 -0
  130. package/src/layout.ts +228 -0
  131. package/src/library.ts +37 -3
  132. package/src/limits.ts +290 -0
  133. package/src/locale.ts +217 -0
  134. package/src/mail.ts +318 -0
  135. package/src/model-credential.ts +71 -0
  136. package/src/model-probe.ts +4 -0
  137. package/src/notify.ts +189 -103
  138. package/src/openapi.ts +5 -3
  139. package/src/operator.ts +300 -0
  140. package/src/paths.ts +54 -0
  141. package/src/platform.ts +52 -3
  142. package/src/provider-health.ts +3 -2
  143. package/src/providers.ts +1368 -6
  144. package/src/refusal.ts +320 -0
  145. package/src/run-container.ts +460 -44
  146. package/src/runner.ts +1168 -155
  147. package/src/runtime.ts +487 -55
  148. package/src/script-tools.ts +81 -10
  149. package/src/secret-files.ts +22 -10
  150. package/src/secret-health.ts +64 -19
  151. package/src/secrets.ts +149 -87
  152. package/src/starter.ts +93 -11
  153. package/src/step-exec.ts +447 -38
  154. package/src/step-removal.ts +59 -0
  155. package/src/storage.ts +141 -22
  156. package/src/store.ts +506 -70
  157. package/src/subagents.ts +189 -0
  158. package/src/test-mode.ts +51 -11
  159. package/src/tool-names.ts +40 -11
  160. package/src/tool-programs.ts +179 -0
  161. package/src/tool-test.ts +110 -12
  162. package/src/translator.ts +72 -22
  163. package/src/trigger-log.ts +1 -0
  164. package/src/triggers.ts +10 -2
  165. package/src/untrusted-fetch.ts +88 -0
  166. package/src/web-actions.ts +237 -0
  167. package/src/webhook.ts +41 -0
  168. package/templates/blog-desk/agents/writer/agent.md +1 -1
  169. package/templates/hello/agents/curator/agent.md +4 -4
  170. package/templates/hello/agents/notetaker/agent.md +3 -3
@@ -6,14 +6,19 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import matter from "gray-matter";
8
8
  import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
9
+ import { cascadeLimits, toolOwners } from "./limits.js";
10
+ import { resolveAgentPath } from "./confine.js";
11
+ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js";
9
12
  import { eventUrl } from "./webhook.js";
10
13
  import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
11
14
  import { hostSafeEnv } from "./host-env.js";
15
+ import { resolveModelCredential, credentialLine } from "./model-credential.js";
12
16
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
13
17
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
18
+ import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
14
19
  import { platform } from "./platform.js";
15
20
  import { healthKey } from "./secret-health.js";
16
- import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
21
+ import { TEST_WRITES_DIR, overlayTestWrites, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
17
22
  /** Does this process run steps in a sandbox — the container core ships, or
18
23
  * one the platform registered (a pod)? */
19
24
  const isolatedRun = () => {
@@ -21,9 +26,12 @@ const isolatedRun = () => {
21
26
  return mode === "container" || (!!mode && mode in platform.isolation);
22
27
  };
23
28
  import { gatherConsults, buildConsultTools } from "./agent-tools.js";
24
- import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError } from "./tool-names.js";
29
+ import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools } from "./subagents.js";
30
+ import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.js";
31
+ import { sendPlainNotification } from "./notify.js";
32
+ import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
25
33
  import { refNames } from "./refs.js";
26
- import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
34
+ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
27
35
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
28
36
  import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.js";
29
37
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
@@ -34,11 +42,17 @@ import { startTranslator, translatorSpecFor } from "./translator.js";
34
42
  import { providerPreset } from "./providers.js";
35
43
  import { buildScriptTools, parseScripts } from "./script-tools.js";
36
44
  import { libraryDir, libraryTools, libraryMemoryIndex } from "./library.js";
37
- import { mergeRuntimes, parseRuntime, prepareRuntime } from "./runtime.js";
45
+ import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime } from "./runtime.js";
38
46
  import { materializeFiles, harvestFiles } from "./storage.js";
39
47
  import { chooseExecutor, ensureImage } from "./container.js";
40
48
  import { stampBundle } from "./okf.js";
49
+ import { readBrowseSettings, readSearchSettings, resolveSearch, searchSettingsEnv, webConfig, browseSessionProblems } from "./providers.js";
50
+ import { NEWER_ACTIONS, resolveActionApi } from "./web-actions.js";
51
+ import { resolveLanguage, languageName } from "./language.js";
52
+ import { resolveRegion, deriveLocale, localeProse, regionName } from "./locale.js";
41
53
  import { trimChars } from "./paths.js";
54
+ import { resolveClock, localDate } from "./clock.js";
55
+ export { localDate } from "./clock.js";
42
56
  // An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
43
57
  // in env and headers resolve server-side, so a credential reaches the server
44
58
  // process without ever entering the model's context.
@@ -213,6 +227,30 @@ export function discoverSkills(agentDir, subdir = "skills") {
213
227
  * Returns null rather than throwing on unparseable YAML: a broken shared file
214
228
  * must not take down every agent under it.
215
229
  */
230
+ /**
231
+ * AGENTS.md without the block a coding tool reads.
232
+ *
233
+ * AGENTS.md is two things in a foldrun folder: the account's (or
234
+ * workspace's) own context, which every running agent is given, and — the
235
+ * convention Next.js set — the file a coding agent (Claude Code, Cursor,
236
+ * Codex) reads before it edits the folder. The CLI writes a short managed
237
+ * block for the second reader between these markers; it is about editing
238
+ * the files, not about doing the work, so no running agent is shown it.
239
+ */
240
+ export const AGENT_RULES_START = "<!-- BEGIN:foldrun-agent-rules -->";
241
+ export const AGENT_RULES_END = "<!-- END:foldrun-agent-rules -->";
242
+ export function withoutAgentRules(body) {
243
+ let out = body;
244
+ for (;;) {
245
+ const start = out.indexOf(AGENT_RULES_START);
246
+ if (start === -1)
247
+ return out;
248
+ const end = out.indexOf(AGENT_RULES_END, start);
249
+ if (end === -1)
250
+ return out;
251
+ out = out.slice(0, start) + out.slice(end + AGENT_RULES_END.length);
252
+ }
253
+ }
216
254
  export function readAgentsMd(dir) {
217
255
  // Convert a pre-rename `mdagent_version:` before parsing. One name in the
218
256
  // code, one on disk — the reader no longer knows two spellings, so a file
@@ -224,7 +262,7 @@ export function readAgentsMd(dir) {
224
262
  continue;
225
263
  try {
226
264
  const parsed = matter(fs.readFileSync(file, "utf8"));
227
- return { data: parsed.data, body: parsed.content.trim() };
265
+ return { data: parsed.data, body: withoutAgentRules(parsed.content).trim() };
228
266
  }
229
267
  catch {
230
268
  return null;
@@ -242,6 +280,53 @@ const workspaceRootOf = (agentDir) => path.join(agentDir, "..", "..");
242
280
  * writing instructions means *as well as*, because an account rule nobody can
243
281
  * silently drop is the point of having one.
244
282
  */
283
+ /** An agent's own `runtime:` block, else its workspace's (AGENTS.md, the
284
+ * Linux Foundation standard name; project.md stays accepted so nothing
285
+ * that already exists breaks). */
286
+ function ownRuntime(front, agentDir, tenant) {
287
+ return parseRuntime(front.runtime) ?? parseRuntime(workspaceFrontmatter(agentDir, tenant).runtime);
288
+ }
289
+ /** What one granted script tool's program needs. */
290
+ function toolRuntime(def) {
291
+ return def.kind === "script" ? parseRuntime(def.spec.runtime) : null;
292
+ }
293
+ /**
294
+ * The runtime one agent's steps are built with: its own declaration (else its
295
+ * workspace's), merged with every script tool it grants. The runner builds
296
+ * exactly this for a step; the platform builds it ahead of time when the
297
+ * workspace is deployed, so a package that will not install is reported at
298
+ * deploy and a scheduled step finds its environment ready. One function, so
299
+ * the two can never disagree about what an agent needs.
300
+ */
301
+ export function agentRuntimeSpec(tenant, workspace, agentDir) {
302
+ const file = path.join(agentDir, "agent.md");
303
+ if (!fs.existsSync(file))
304
+ return null;
305
+ const front = safeMatter(fs.readFileSync(file, "utf8")).data;
306
+ const available = { ...libraryTools(tenant), ...workspaceTools(tenant, workspace) };
307
+ const tools = ownToolNames(front)
308
+ .map((name) => available[name])
309
+ .filter((def) => Boolean(def))
310
+ .map(toolRuntime);
311
+ return mergeRuntimes(ownRuntime(front, agentDir, tenant), ...tools);
312
+ }
313
+ /** Every distinct runtime a workspace's agents need, with who needs it. */
314
+ export function workspaceRuntimePlan(tenant, workspace) {
315
+ const byFp = new Map();
316
+ const agentsRoot = path.join(workspaceDir(tenant, workspace), "agents");
317
+ if (!fs.existsSync(agentsRoot))
318
+ return [];
319
+ for (const name of fs.readdirSync(agentsRoot).sort()) {
320
+ const spec = agentRuntimeSpec(tenant, workspace, path.join(agentsRoot, name));
321
+ if (!spec)
322
+ continue;
323
+ const fp = fingerprint(spec);
324
+ const entry = byFp.get(fp) ?? { fingerprint: fp, spec, agents: [] };
325
+ entry.agents.push(name);
326
+ byFp.set(fp, entry);
327
+ }
328
+ return [...byFp.values()];
329
+ }
245
330
  function workspaceFrontmatter(agentDir, tenant) {
246
331
  return {
247
332
  ...(readAgentsMd(accountDir(tenant))?.data ?? {}),
@@ -249,31 +334,60 @@ function workspaceFrontmatter(agentDir, tenant) {
249
334
  };
250
335
  }
251
336
  /**
252
- * The calendar an agent works to. `timezone:` in AGENTS.md, nearest-wins like
253
- * every other key there; a flow's cron already means its own timezone, this
254
- * makes "today" mean it too. Unset is UTC — what the pods run — which for a
255
- * Sydney desk made an article dated today "one day in the future" until 10am.
256
- * An unknown name falls back to UTC rather than failing every step.
337
+ * The calendar a set of frontmatter asks for, with no cascade around it —
338
+ * one merged record in, one zone out. The full cascade lives in
339
+ * `agentClock` below and in clock.ts; this is what the account-level parts
340
+ * of the platform (a wallet's month, a budget window) call.
257
341
  */
258
342
  export function resolveTimezone(front) {
259
- const tz = typeof front.timezone === "string" ? front.timezone.trim() : "";
260
- const chosen = tz || process.env.FOLDRUN_TIMEZONE?.trim() || "UTC";
261
- try {
262
- new Intl.DateTimeFormat("en-CA", { timeZone: chosen });
263
- return chosen;
264
- }
265
- catch {
266
- return "UTC";
267
- }
343
+ return resolveClock([{ level: "workspace", value: front.timezone }]).timezone;
268
344
  }
269
- /** YYYY-MM-DD in a timezone — the date the workspace believes it is. */
270
- export function localDate(timeZone, now = new Date()) {
271
- return new Intl.DateTimeFormat("en-CA", {
272
- timeZone,
273
- year: "numeric",
274
- month: "2-digit",
275
- day: "2-digit",
276
- }).format(now);
345
+ /**
346
+ * The clock this step works to: agent, then flow, then workspace, then
347
+ * account, then FOLDRUN_TIMEZONE, then UTC. Nearest wins, and a value that
348
+ * is not a zone falls through to the next level with a line to say so —
349
+ * see clock.ts for why this exists at all.
350
+ */
351
+ export function agentClock(agentDir, tenant, agentFront, flowTimezone) {
352
+ return resolveClock([
353
+ { level: "agent", value: agentFront.timezone },
354
+ { level: "flow", value: flowTimezone },
355
+ { level: "workspace", value: readAgentsMd(workspaceRootOf(agentDir))?.data?.timezone },
356
+ { level: "account", value: readAgentsMd(accountDir(tenant))?.data?.timezone },
357
+ ]);
358
+ }
359
+ /**
360
+ * The language this step works in: agent, then workspace, then account, then
361
+ * FOLDRUN_LANGUAGE, then English. The flow level is not read yet — a flow's
362
+ * timezone: travels through three signatures to get here, and this will
363
+ * take the same road when a flow needs it.
364
+ */
365
+ export function agentLanguage(agentDir, tenant, agentFront) {
366
+ return resolveLanguage([
367
+ { level: "agent", value: agentFront.language },
368
+ { level: "workspace", value: readAgentsMd(workspaceRootOf(agentDir))?.data?.language },
369
+ { level: "account", value: readAgentsMd(accountDir(tenant))?.data?.language },
370
+ ]);
371
+ }
372
+ /**
373
+ * The country this step works for, on the same road as the language, then
374
+ * FOLDRUN_REGION, then whatever the language tag carried (`en-AU` says AU).
375
+ * Everything a country changes — currency, units, calendar, weekend, text
376
+ * direction — is derived from it in locale.ts; the three overrides
377
+ * (`currency:`, `calendar:`, `units:`) are read at the nearest level that
378
+ * wrote them.
379
+ */
380
+ export function agentRegion(agentDir, tenant, agentFront, languageTag) {
381
+ return resolveRegion([
382
+ { level: "agent", value: agentFront.region },
383
+ { level: "workspace", value: readAgentsMd(workspaceRootOf(agentDir))?.data?.region },
384
+ { level: "account", value: readAgentsMd(accountDir(tenant))?.data?.region },
385
+ ], languageTag);
386
+ }
387
+ export function agentLocaleOverrides(agentDir, tenant, agentFront) {
388
+ const levels = [agentFront, readAgentsMd(workspaceRootOf(agentDir))?.data ?? {}, readAgentsMd(accountDir(tenant))?.data ?? {}];
389
+ const pick = (k) => levels.map((l) => l[k]).find((v) => v !== undefined && v !== null && v !== "");
390
+ return { currency: pick("currency"), calendar: pick("calendar"), units: pick("units") };
277
391
  }
278
392
  /**
279
393
  * The prose from AGENTS.md, outermost first.
@@ -282,6 +396,56 @@ export function localDate(timeZone, now = new Date()) {
282
396
  * template as "context every agent here shares" — and never read. Only the
283
397
  * frontmatter was, so the instructions people actually wrote reached no model.
284
398
  */
399
+ /**
400
+ * The sub-agents a step may delegate to (`subagents:`), as the SDK will get
401
+ * them: each resolved through agentContext exactly as its own step would be
402
+ * (grants only — no runtime; it runs inside its parent's), then cut to what
403
+ * the parent holds. Problems are lines on the step, never a failed step.
404
+ */
405
+ export function buildSubagentSpecs(o) {
406
+ const out = [];
407
+ const { found, missing } = gatherSubagents(o.workspaceRoot, o.names);
408
+ for (const m of missing)
409
+ o.push("error", `subagents: "${m}" is not an agent in this workspace — not delegated to`);
410
+ for (const sub of found) {
411
+ if (sub.name === o.self) {
412
+ o.push("error", `subagents: "${sub.name}" is this agent itself — an agent does not delegate to itself`);
413
+ continue;
414
+ }
415
+ try {
416
+ const tags = o.tags ?? [];
417
+ const own = agentContext(sub.dir, o.tenant, tags, { runId: o.runId, agent: sub.name }, o.flowTimezone, true);
418
+ const description = typeof own.front.description === "string" ? own.front.description.trim() : "";
419
+ if (!description) {
420
+ o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
421
+ continue;
422
+ }
423
+ const tools = subagentTools(own.allowed, o.parentAllowed);
424
+ if (!tools.length) {
425
+ o.push("error", `subagents: "${sub.name}" holds none of its tools inside this step — it can only answer from its prompt (use agents: for that)`);
426
+ }
427
+ const body = withoutAgentRules(matter(fs.readFileSync(path.join(sub.dir, "agent.md"), "utf8")).content);
428
+ out.push({
429
+ name: sub.name,
430
+ description,
431
+ prompt: subagentPrompt({
432
+ body: resolveDocLinks(body, o.workspaceRoot),
433
+ name: sub.name,
434
+ parent: o.self,
435
+ workspace: path.basename(o.workspaceRoot),
436
+ shared: sharedInstructions(sub.dir, o.tenant),
437
+ }),
438
+ tools,
439
+ disallowedTools: refNames(own.front.disallowedTools),
440
+ model: own.front.model ? resolveModel(own.front.model) : "inherit",
441
+ });
442
+ }
443
+ catch (err) {
444
+ o.push("error", `subagents: "${sub.name}" could not be read — ${err instanceof Error ? err.message : String(err)}`);
445
+ }
446
+ }
447
+ return out;
448
+ }
285
449
  export function sharedInstructions(agentDir, tenant) {
286
450
  const sections = [
287
451
  ["Everyone in this account", readAgentsMd(accountDir(tenant))?.body],
@@ -359,8 +523,10 @@ export function resolveDocLinks(text, workspaceRoot) {
359
523
  // instruction names needs to say WHERE without naming a file. Added
360
524
  // first, so a document that happens to share a folder's name still wins.
361
525
  for (const dir of [STORAGE_DIR, "state", "knowledge", "memory", "skills", "outputs"]) {
526
+ // storage/ and state/ by the one spelling that works in every tool,
527
+ // the shell included; the rest as they always were.
362
528
  if (fs.existsSync(path.join(workspaceRoot, dir)))
363
- map.set(norm(dir), `../../${dir}/`);
529
+ map.set(norm(dir), dir === STORAGE_DIR || dir === "state" ? `workspace/${dir}/` : `../../${dir}/`);
364
530
  }
365
531
  for (const kind of ["knowledge", "memory"]) {
366
532
  const dir = path.join(workspaceRoot, kind);
@@ -405,7 +571,7 @@ export function resolveDocLinks(text, workspaceRoot) {
405
571
  continue;
406
572
  }
407
573
  const fwd = entry.split(path.sep).join("/");
408
- const rel = `../../state/${fwd}`;
574
+ const rel = `workspace/state/${fwd}`;
409
575
  map.set(norm(`state/${fwd}`), rel);
410
576
  map.set(norm(fwd), rel);
411
577
  }
@@ -417,7 +583,7 @@ export function resolveDocLinks(text, workspaceRoot) {
417
583
  // through to the plain-directory walk below.
418
584
  const index = JSON.parse(fs.readFileSync(path.join(workspaceRoot, "..", "..", STORAGE_DIR, path.basename(workspaceRoot), "index.json"), "utf8"));
419
585
  for (const f of index.files ?? [])
420
- map.set(norm(`${STORAGE_DIR}/${f.path}`), `../../${STORAGE_DIR}/${f.path}`);
586
+ map.set(norm(`${STORAGE_DIR}/${f.path}`), `workspace/${STORAGE_DIR}/${f.path}`);
421
587
  }
422
588
  catch {
423
589
  // no file store index is normal
@@ -436,8 +602,8 @@ export function resolveDocLinks(text, workspaceRoot) {
436
602
  const fwd = entry.split(path.sep).join("/");
437
603
  if (fwd.startsWith(".store/"))
438
604
  continue;
439
- map.set(norm(`${STORAGE_DIR}/${fwd}`), `../../${STORAGE_DIR}/${fwd}`);
440
- map.set(norm(fwd), `../../${STORAGE_DIR}/${fwd}`);
605
+ map.set(norm(`${STORAGE_DIR}/${fwd}`), `workspace/${STORAGE_DIR}/${fwd}`);
606
+ map.set(norm(fwd), `workspace/${STORAGE_DIR}/${fwd}`);
441
607
  }
442
608
  }
443
609
  // `[` excluded from the name: with it allowed, a run of `[[` makes the
@@ -450,7 +616,13 @@ export function resolveDocLinks(text, workspaceRoot) {
450
616
  }
451
617
  function agentContext(agentDir, tenant, tags = [],
452
618
  /** Who is running, so scripts can stamp what they write with its provenance. */
453
- identity = {}) {
619
+ identity = {},
620
+ /** The flow's own `timezone:`, when this step belongs to one. Between the
621
+ * agent's frontmatter and the workspace's AGENTS.md in the cascade. */
622
+ flowTimezone,
623
+ /** Only what the agent is granted — no runtime prepared. For a sub-agent,
624
+ * whose tools are cut to its parent's and run in the parent's runtime. */
625
+ grantsOnly = false) {
454
626
  // agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
455
627
  // workspace a secret should resolve against is two levels up.
456
628
  const workspace = path.basename(path.resolve(agentDir, "..", ".."));
@@ -461,12 +633,33 @@ identity = {}) {
461
633
  // than implied: told to read `../../knowledge/index.md`, the model asked for
462
634
  // `/knowledge/index.md` twice before working it out. Two denials and a turn,
463
635
  // for want of one sentence.
636
+ // The language this step works in, resolved before the prompt is built:
637
+ // its sentence goes in here, the env the tools read is set below, and the
638
+ // step prints any level that wrote something unreadable.
639
+ const language = agentLanguage(agentDir, tenant, front);
640
+ const region = agentRegion(agentDir, tenant, front, language.language);
641
+ // The clock is resolved further down for the env; the locale needs its
642
+ // zone now so "today" in a Jalali or Hijri calendar is today where the
643
+ // step works, not in UTC.
644
+ const localeZone = agentClock(agentDir, tenant, front, flowTimezone).timezone;
645
+ const locale = deriveLocale(language.language, region.region, agentLocaleOverrides(agentDir, tenant, front), localeZone);
646
+ // Said only when someone set it: an agent that never mentions a language
647
+ // or a region reads exactly the prompt it always did.
648
+ const languageSet = language.from !== "default" && language.from !== "env";
649
+ const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
650
+ if (languageSet || regionSet) {
651
+ parts.push(`# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
652
+ `Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
653
+ `use these currency, units and calendar conventions in anything you produce. ` +
654
+ `Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`);
655
+ }
464
656
  parts.push(`# Where you are\n\n` +
465
657
  `Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
466
658
  `\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
467
- `\`outputs/\` is yours, and \`../../\` is the workspace root, so the workspace's ` +
468
- `own knowledge is at \`../../knowledge/\`. Absolute paths are outside the ` +
469
- `workspace and will be refused.\n\n` +
659
+ `\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
660
+ `and in the shell: the shared file store is \`workspace/storage/\`, what runs ` +
661
+ `carry forward is \`workspace/state/\`, its knowledge \`workspace/knowledge/\`. ` +
662
+ `Absolute paths are outside the workspace and will be refused.\n\n` +
470
663
  // A flow's whole point is that a later step works on what an earlier one
471
664
  // produced, and each agent writes to its own outputs/. A run showed what
472
665
  // omitting this costs: the checker looked in its own empty outputs/ and
@@ -487,10 +680,18 @@ identity = {}) {
487
680
  // adoptLegacyFilesDir's "move files/ to storage/" is a no-op by the time
488
681
  // the run ends — a deliverable written where this text pointed was
489
682
  // harvested from nowhere and silently never appeared.
490
- `\`../../storage/\` is the workspace file store: anything you leave there is kept ` +
683
+ `\`workspace/storage/\` is the workspace file store: anything you leave there is kept ` +
491
684
  `after the run and shown on the Storage page for people to download. Write ` +
492
- `deliverables people asked for — CSVs, reports, PDFs, images — to \`../../storage/\`; ` +
493
- `use \`outputs/\` for working text the next step reads.`);
685
+ `deliverables people asked for — CSVs, reports, PDFs, images — to \`workspace/storage/\`; ` +
686
+ `use \`outputs/\` for working text the next step reads. ` +
687
+ // A Glob PATTERN that climbs (`../../storage/draft/*.md`) always answered
688
+ // "No files found", even when the file was there. On blog-desk that made
689
+ // every loop pass look like a first pass: the illustrator never saw the
690
+ // fact-check report, re-picked the cover, and 2 of 3 publish runs failed
691
+ // (run-munef2am-439a, 2026-09-30). checkPaths now splits such a pattern
692
+ // into Glob's `path` and the rest; the advice stays, as the plain form.
693
+ `To look for files there, open a known file with Read by its exact path, or ` +
694
+ `Glob with \`path: "workspace/storage/..."\` and a pattern like \`*.md\`.`);
494
695
  // Shared context before anything derived — an account or workspace rule is
495
696
  // background the rest of the prompt is read against, not an afterthought.
496
697
  const sharedContext = sharedInstructions(agentDir, tenant);
@@ -506,25 +707,7 @@ identity = {}) {
506
707
  // skills for a few tokens each.
507
708
  // Own skills, then the workspace's, then the workspace library — nearest
508
709
  // definition of a name wins, so a team default can be overridden locally.
509
- const skills = [];
510
- const seenSkills = new Set();
511
- const addSkills = (dir, prefix, subdir = "skills") => {
512
- for (const skill of discoverSkills(dir, subdir)) {
513
- if (seenSkills.has(skill.name))
514
- continue;
515
- seenSkills.add(skill.name);
516
- skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
517
- }
518
- };
519
- const workspaceRoot = path.join(agentDir, "..", "..");
520
- addSkills(agentDir, "");
521
- addSkills(workspaceRoot, "../../"); // the workspace's skills/
522
- // The cross-client convention: skills any skills-compatible tool installs
523
- // under .agents/skills/ are visible here too, and vice versa. Scanned after
524
- // the native location so a foldrun-native skill wins a name clash, but both
525
- // are discovered. project scope only — foldrun workspaces are self-contained.
526
- addSkills(workspaceRoot, "../../", ".agents/skills");
527
- addSkills(path.join(libraryDir(tenant)), "../../../../library/"); // the account library
710
+ const skills = skillsInScope(agentDir, tenant);
528
711
  // A skill with `when:` only loads when the run carries a matching tag —
529
712
  // so an agent with 119 skills costs 119 lines of context only when every
530
713
  // one is genuinely relevant.
@@ -603,7 +786,7 @@ identity = {}) {
603
786
  return out.sort();
604
787
  };
605
788
  const own = listDir(path.join(agentDir, "scripts"), "scripts/");
606
- const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "../../scripts/");
789
+ const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "workspace/scripts/");
607
790
  // The library's path depends on where this run executes: isolated runs see
608
791
  // it at /library, host runs at its real location. Printing the host path
609
792
  // into a container's prompt handed the model files it could never open.
@@ -628,7 +811,7 @@ identity = {}) {
628
811
  // for it. The difference is that state is small enough that inlining would
629
812
  // usually be free — that is a threshold worth adding once there is a real
630
813
  // file to size it against, not before.
631
- const state = listDir(path.join(agentDir, "..", "..", "state"), "../../state/");
814
+ const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
632
815
  if (state.length) {
633
816
  parts.push(`# State — what you carry between runs\n\n` +
634
817
  `What this workspace keeps across runs: a count, a cursor, where you got to. ` +
@@ -662,17 +845,32 @@ identity = {}) {
662
845
  // below. The tool is the unit of code, so its dependencies live in tool.md
663
846
  // and travel with it; the agent should not have to repeat them.
664
847
  const toolRuntimes = [];
848
+ // The secrets a granted script tool's file lists (`secrets: [RESEND_API_KEY,
849
+ // EMAIL_FROM]`): granting the tool grants them, the way an API tool's
850
+ // referenced credentials always travelled with it. Before, only the
851
+ // agent's own `secrets:` counted, so an agent that granted desk_email and
852
+ // declared nothing failed with "RESEND_API_KEY is not set" (strata-desk,
853
+ // 1 Oct). `proxied` / `materialised` say how, not what, and add nothing.
854
+ const toolSecrets = [];
855
+ // Does any granted tool act outside the workspace (`outward: true`)? The
856
+ // retry policy reads it: see launchStep.
857
+ let outward = false;
665
858
  const grantOwnTool = (name) => {
666
859
  const def = available[name];
667
860
  if (!def) {
668
861
  missingTools.push(name);
669
862
  return;
670
863
  }
864
+ if (def.outward)
865
+ outward = true;
671
866
  if (def.kind === "http")
672
867
  apis.push(def.spec);
673
868
  else if (def.kind === "script") {
674
869
  scriptSpecs.push(...parseScripts([def.spec]));
675
- const rt = parseRuntime(def.spec.runtime);
870
+ const listed = def.spec.secrets;
871
+ if (Array.isArray(listed))
872
+ toolSecrets.push(...listed.map(String).filter((n) => /^[A-Z][A-Z0-9_]*$/.test(n)));
873
+ const rt = toolRuntime(def);
676
874
  if (rt)
677
875
  toolRuntimes.push(rt);
678
876
  }
@@ -716,7 +914,69 @@ identity = {}) {
716
914
  //
717
915
  // Declared-and-absent is still an error; referenced-and-absent is the API
718
916
  // tool's own missingSecrets, reported where the tool is described.
719
- const declared = Array.isArray(front.secrets) ? front.secrets.map(String) : [];
917
+ // `web: {search: …, fetch: …}` name who answers; resolved here, early,
918
+ // because three later things hang off the answer — the secret the step
919
+ // must hold, the env the tool reads, and the egress grant for the host.
920
+ // `web.search` carries the account engine's settings (engines, categories,
921
+ // safesearch, …) beside the provider name, read off first the way
922
+ // browse's are, so the provider resolver sees only its own part.
923
+ const web = webConfig(front, workspaceFrontmatter(agentDir, tenant));
924
+ const searchRead = readSearchSettings(web.raw.search);
925
+ const searchChoice = resolveSearch(searchRead.rest, "search");
926
+ if (searchRead.error)
927
+ searchChoice.error = searchChoice.error ?? searchRead.error;
928
+ const fetchChoice = resolveSearch(web.raw.fetch, "fetch");
929
+ // A remote browser's key is materialised, not proxied: CDP is a websocket
930
+ // and the wrapper opens the session itself, the way it seeds a cookie.
931
+ // `web.browse` carries two things in one key: how the browser presents
932
+ // itself (engine, user agent, device, locale, timezone) and, optionally,
933
+ // which remote vendor renders the page. Settings are read off first so the
934
+ // vendor resolver sees only the vendor part, and a block of settings alone
935
+ // means the account's own browser with those defaults. Nearest wins: the
936
+ // agent's block, else the workspace's, else the account's — the shape
937
+ // `provider:` already uses.
938
+ const browseRead = readBrowseSettings(web.raw.browse);
939
+ const browseSettings = browseRead.settings;
940
+ const browseChoice = resolveSearch(browseRead.rest, "browse");
941
+ if (browseRead.error)
942
+ browseChoice.error = browseChoice.error ?? browseRead.error;
943
+ // What the vendor's own session is asked for, checked against what that
944
+ // vendor offers; a problem is on the record and the session is not sent.
945
+ const sessionProblems = browseChoice.error ? [] : browseSessionProblems(browseSettings, browseChoice.provider);
946
+ const browseSession = browseChoice.shape === "direct" && browseChoice.provider && !sessionProblems.length ? browseSettings.vendor_session : undefined;
947
+ // crawl, map, extract, answer, monitor: a provider each, or foldrun's own.
948
+ // Same three consequences as a search API — the secret the step holds, the
949
+ // env the web tool reads, the one host the key may reach.
950
+ const actionChoices = NEWER_ACTIONS.map((action) => ({ action, ...resolveActionApi(action, web.raw[action]) }));
951
+ const builtins = [
952
+ ...(searchChoice.provider && searchChoice.shape !== "direct" && !searchChoice.error ? ["search=WebSearch"] : []),
953
+ ...(fetchChoice.provider && fetchChoice.shape !== "direct" && !fetchChoice.error ? ["fetch=WebFetch"] : []),
954
+ ];
955
+ const webBuiltin = builtins.length ? { FOLDRUN_WEB_BUILTIN: builtins.join(",") } : {};
956
+ // A direct search API is paid for with the customer's own key, stored
957
+ // under a fixed vault name (EXA_API_KEY, BRAVE_SEARCH_API_KEY, …). Naming
958
+ // the API in frontmatter is declaring that secret — nobody should have to
959
+ // write it twice, and a run that forgot the second line would search
960
+ // nothing and say so only at the provider.
961
+ const declared = [
962
+ ...(Array.isArray(front.secrets) ? front.secrets.map(String) : []),
963
+ ...toolSecrets,
964
+ ...actionChoices.flatMap((c) => (c.provider ? [c.secret] : [])),
965
+ // Your own proxy for a vendor's session: its URL is a secret, declared by
966
+ // naming it, like a provider's key.
967
+ ...(browseSession && typeof browseSession.proxy === "object" && browseSession.proxy.own ? [browseSession.proxy.own] : []),
968
+ ...[searchChoice, fetchChoice, browseChoice].flatMap((c) => {
969
+ if (c.shape !== "direct" || !c.secret)
970
+ return [];
971
+ // Jina's reader answers without a key. Declaring an absent optional
972
+ // key would fail the step for a credential it never needed.
973
+ if (c.secretOptional && !getSecret(tenant, c.secret, workspace))
974
+ return [];
975
+ return [c.secret];
976
+ }),
977
+ ];
978
+ // One name once, however many places asked for it.
979
+ declared.splice(0, declared.length, ...new Set(declared));
720
980
  const referenced = [...new Set(apis.flatMap(secretsUsedByApi))];
721
981
  const { env: secretEnv, from: secretScopes, } = resolveSecrets(tenant, [...new Set([...declared, ...referenced])], workspace);
722
982
  const { missing: missingDeclared } = resolveSecrets(tenant, declared, workspace);
@@ -822,15 +1082,8 @@ identity = {}) {
822
1082
  // Runtime: an agent's own `runtime:` block, else its workspace's. Built once
823
1083
  // per declaration and cached, so scripts get their dependencies without
824
1084
  // polluting the host.
825
- let runtimeSpec = parseRuntime(front.runtime);
826
- if (!runtimeSpec) {
827
- // AGENTS.md is the Linux Foundation standard name (60k+ repos, same
828
- // nearest-wins cascade we already use); project.md stays accepted so
829
- // nothing that already exists breaks.
830
- runtimeSpec = parseRuntime(workspaceFrontmatter(agentDir, tenant).runtime);
831
- }
832
1085
  // Plus whatever the tools this agent granted declared for themselves.
833
- runtimeSpec = mergeRuntimes(runtimeSpec, ...toolRuntimes);
1086
+ let runtimeSpec = mergeRuntimes(ownRuntime(front, agentDir, tenant), ...toolRuntimes);
834
1087
  // Execution: a container per run when Docker is available (real isolation),
835
1088
  // otherwise the host venv path so local development still works.
836
1089
  const executor = chooseExecutor();
@@ -838,40 +1091,122 @@ identity = {}) {
838
1091
  let runtimeError = null;
839
1092
  let exec = null;
840
1093
  let runtime = { interpreters: {}, env: {}, log: [], error: null };
841
- if (executor === "docker") {
842
- const image = ensureImage(runtimeSpec);
1094
+ if (executor === "docker" && !grantsOnly) {
1095
+ // The script image also carries what the scripts' own languages need: a
1096
+ // .mjs tool with no runtime: block still needs node in its container.
1097
+ // Here only — the run container and the host have node already, and a
1098
+ // spec there sets off the runtime build for nothing.
1099
+ const image = ensureImage(mergeRuntimes(runtimeSpec, scriptLanguages(scriptSpecs)));
843
1100
  runtimeLog.push(...image.log);
844
1101
  runtimeError = image.error;
845
1102
  if (!image.error) {
846
1103
  exec = {
847
1104
  executor: "docker",
848
1105
  image: image.tag,
849
- mounts: {
850
- [path.resolve(agentDir, "..", "..", "scripts")]: "/workspace-scripts",
851
- [libraryDir(tenant, "scripts")]: "/library-scripts",
852
- },
1106
+ mounts: scriptMounts(agentDir, tenant),
853
1107
  // Only agents that granted something reaching out — an `apis:` entry
854
1108
  // or one of their own tools — get egress.
855
1109
  network: parseApis(front.apis).length > 0 || ownToolNames(front).length > 0,
856
1110
  };
857
1111
  }
858
1112
  }
1113
+ else if (isolatedRun() || grantsOnly) {
1114
+ // (A sub-agent's grants are all that is asked of it: it runs inside its
1115
+ // parent's step and runtime.)
1116
+ // The sandbox builds it (run-container.ts, beside pip's network and the
1117
+ // mounted cache); building it here as well was pure waste, and on a
1118
+ // worker image without python it was worse than waste — it logged
1119
+ // "python3 is not available on this host" as an error on every Python
1120
+ // step, above a sandbox build that had succeeded, and sent every reader
1121
+ // of the trace after the wrong machine.
1122
+ }
859
1123
  else {
860
1124
  runtime = prepareRuntime(tenant, runtimeSpec);
861
1125
  runtimeLog.push(...runtime.log);
862
1126
  runtimeError = runtime.error;
863
1127
  }
864
- // What day it is, in the workspace's own timezone. Not a secret, so it
865
- // travels beside secretEnv rather than inside it — a value in there is
866
- // redacted from every log line that quotes it.
867
- const timezone = resolveTimezone(workspaceFrontmatter(agentDir, tenant));
1128
+ // What day it is, in the calendar this step works to — the agent's own
1129
+ // `timezone:`, else its flow's, else the workspace's, else the account's.
1130
+ // Not a secret, so it travels beside secretEnv rather than inside it — a
1131
+ // value in there is redacted from every log line that quotes it.
1132
+ const clock = agentClock(agentDir, tenant, front, flowTimezone);
1133
+ const timezone = clock.timezone;
868
1134
  const clockEnv = {
869
- TZ: timezone,
1135
+ // The language the agent works in — what the search asks in, the locale
1136
+ // the browser reports, the Accept-Language a fetch sends. Not a secret.
1137
+ FOLDRUN_LANGUAGE: language.language,
1138
+ // The country and what follows from it — web search's gl, a script's
1139
+ // currency. Unset where nobody said, so nothing is invented.
1140
+ ...(region.region ? { FOLDRUN_REGION: region.region } : {}),
1141
+ ...(locale.currency ? { FOLDRUN_CURRENCY: locale.currency } : {}),
1142
+ FOLDRUN_UNITS: locale.units,
1143
+ FOLDRUN_CALENDAR: locale.calendar,
1144
+ ...(locale.dateLocal ? { FOLDRUN_DATE_LOCAL: locale.dateLocal } : {}),
1145
+ FOLDRUN_RTL: locale.direction === "rtl" ? "1" : "0",
1146
+ // TZ is the form the sandbox's shell and Node both read; for a fixed
1147
+ // offset that is not the same string Intl was given (see clock.ts).
1148
+ TZ: clock.tz,
870
1149
  FOLDRUN_DATE: localDate(timezone),
871
1150
  // Where the account's own search engine answers, when the install runs
872
1151
  // one — the `websearch` library tool reads it. Not a secret; rides with
873
1152
  // the clock so it reaches scripts, the sandbox and verify alike.
874
1153
  ...(process.env.FOLDRUN_SEARCH_URL ? { FOLDRUN_SEARCH_URL: process.env.FOLDRUN_SEARCH_URL } : {}),
1154
+ // Whose index our web search asks when the agent named a search API.
1155
+ // Unset means the account's own engine. The name, never the key.
1156
+ ...(searchChoice.shape === "direct" && searchChoice.provider
1157
+ ? { FOLDRUN_WEB_SEARCH_VIA: searchChoice.provider, FOLDRUN_WEB_SEARCH_SECRET: searchChoice.secret ?? "" }
1158
+ : {}),
1159
+ // The `web.search` block's SearXNG settings, as defaults the tool reads.
1160
+ ...searchSettingsEnv(searchRead.settings),
1161
+ ...(fetchChoice.shape === "direct" && fetchChoice.provider
1162
+ ? { FOLDRUN_WEB_FETCH_VIA: fetchChoice.provider, FOLDRUN_WEB_FETCH_SECRET: fetchChoice.secret ?? "" }
1163
+ : {}),
1164
+ ...Object.fromEntries(actionChoices.flatMap((c) => (c.provider
1165
+ ? [[`FOLDRUN_WEB_${c.action.toUpperCase()}_VIA`, c.provider], [`FOLDRUN_WEB_${c.action.toUpperCase()}_SECRET`, c.secret]]
1166
+ : []))),
1167
+ // Which actions the web tool may take (`web: {actions: [...]}`); unset is all.
1168
+ ...(web.actions ? { FOLDRUN_WEB_ACTIONS: web.actions.join(",") } : {}),
1169
+ // A model provider's own search or fetch, which the web tool hands over to.
1170
+ ...webBuiltin,
1171
+ // A remote browser: the wrapper connects over CDP to this vendor with
1172
+ // the named key, and the platform leaves the account's pod alone.
1173
+ ...(browseChoice.shape === "direct" && browseChoice.provider
1174
+ ? { FOLDRUN_BROWSER_VENDOR: browseChoice.provider, FOLDRUN_BROWSER_SECRET: browseChoice.secret ?? "" }
1175
+ : {}),
1176
+ // The vendor session's settings (`web.browse.vendor_session:`), as JSON
1177
+ // the tool maps onto that vendor's own option names. Under both names
1178
+ // for one release: a web tool from before the rename reads the old one.
1179
+ ...(browseSession ? { FOLDRUN_BROWSER_VENDOR_SESSION: JSON.stringify(browseSession), FOLDRUN_BROWSER_SESSION: JSON.stringify(browseSession) } : {}),
1180
+ // The `web.browse` block, as defaults the tool reads when the call did
1181
+ // not say otherwise. Env rather than arguments because the tool is a
1182
+ // script the model calls: a default nobody typed should not have to be
1183
+ // typed by the model either.
1184
+ ...(browseSettings.engine ? { FOLDRUN_BROWSER_ENGINE: browseSettings.engine } : {}),
1185
+ ...(browseSettings.user_agent ? { FOLDRUN_BROWSER_USER_AGENT: browseSettings.user_agent } : {}),
1186
+ ...(browseSettings.device ? { FOLDRUN_BROWSER_DEVICE: browseSettings.device } : {}),
1187
+ ...(browseSettings.locale ? { FOLDRUN_BROWSER_LOCALE: browseSettings.locale } : {}),
1188
+ ...(browseSettings.timezone ? { FOLDRUN_BROWSER_TIMEZONE: browseSettings.timezone } : {}),
1189
+ ...(browseSettings.cookies ? { FOLDRUN_BROWSER_COOKIES: browseSettings.cookies } : {}),
1190
+ ...(browseSettings.cookie_domain ? { FOLDRUN_BROWSER_COOKIE_DOMAIN: browseSettings.cookie_domain } : {}),
1191
+ ...(browseSettings.storage ? { FOLDRUN_BROWSER_STORAGE: browseSettings.storage } : {}),
1192
+ ...(browseSettings.storage_origin ? { FOLDRUN_BROWSER_STORAGE_ORIGIN: browseSettings.storage_origin } : {}),
1193
+ // The named identities, as one JSON map the tool picks from by name.
1194
+ ...(browseSettings.identities ? { FOLDRUN_BROWSER_IDENTITIES: JSON.stringify(browseSettings.identities) } : {}),
1195
+ // Only the non-default travels: a window, on a virtual screen if need be.
1196
+ ...(browseSettings.headless === false ? { FOLDRUN_BROWSER_HEADLESS: "0" } : {}),
1197
+ ...(browseSettings.version ? { FOLDRUN_BROWSER_VERSION: browseSettings.version } : {}),
1198
+ ...(browseSettings.live ? { FOLDRUN_BROWSER_LIVE: "1" } : {}),
1199
+ // What the agent may do, from its file: a lock the call cannot widen.
1200
+ ...(browseSettings.allowed_domains ? { FOLDRUN_BROWSER_ALLOWED_DOMAINS: browseSettings.allowed_domains.join(",") } : {}),
1201
+ ...(browseSettings.deny ? { FOLDRUN_BROWSER_DENY: browseSettings.deny.join(",") } : {}),
1202
+ ...(browseSettings.boundaries ? { FOLDRUN_BROWSER_BOUNDARIES: "1" } : {}),
1203
+ ...(browseSettings.init ? { FOLDRUN_BROWSER_INIT: browseSettings.init.join(",") } : {}),
1204
+ ...(browseSettings.extensions ? { FOLDRUN_BROWSER_EXTENSIONS: browseSettings.extensions.join(",") } : {}),
1205
+ ...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
1206
+ ...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
1207
+ ...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
1208
+ ...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
1209
+ ...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
875
1210
  };
876
1211
  // Scripts declared as tools — callable by name, no bash required.
877
1212
  const scriptTools = buildScriptTools(agentDir, scriptSpecs,
@@ -901,7 +1236,7 @@ identity = {}) {
901
1236
  }
902
1237
  parts.push("Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
903
1238
  "what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
904
- "for the workspace root (../../storage/ and workspace/storage/ are the same place). There is no /tmp for you: a " +
1239
+ "for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
905
1240
  "directory a tool made outside the workspace, such as a checkout, is reached only through that tool.");
906
1241
  // The run's one-line summary is the first meaningful line of the last step
907
1242
  // that produced a result — read, never declared (runSummary in store.ts).
@@ -923,15 +1258,49 @@ identity = {}) {
923
1258
  const disabled = [];
924
1259
  const unknownTools = [];
925
1260
  const shadowed = [];
1261
+ // `web: {search: …, fetch: …}` name who answers. Unset means ours — the
1262
+ // account's own engine, and our own fetch, both inside the run's sandbox
1263
+ // and both on the run record. A model provider's name adds the SDK's
1264
+ // server-side tool beside ours, which the run's endpoint executes: point
1265
+ // the run at z.ai and z.ai's index answers the search. The web tool hands
1266
+ // the action over to it (FOLDRUN_WEB_BUILTIN).
1267
+ const providerWebTools = [];
1268
+ if (browseChoice.error)
1269
+ providerWarnings.push(browseChoice.error);
1270
+ providerWarnings.push(...sessionProblems, ...web.problems, ...actionChoices.flatMap((c) => (c.error ? [c.error] : [])));
1271
+ for (const [choice, builtin] of [
1272
+ [searchChoice, "WebSearch"],
1273
+ [fetchChoice, "WebFetch"],
1274
+ ]) {
1275
+ if (choice.error) {
1276
+ providerWarnings.push(choice.error);
1277
+ }
1278
+ else if (choice.provider && choice.shape !== "direct") {
1279
+ // A provider's server-side tool replaces ours. A direct search API
1280
+ // does not: our tool stays, and reads FOLDRUN_WEB_SEARCH_VIA to know
1281
+ // whose index to ask — through the egress proxy, with the customer's
1282
+ // own key, on the run record.
1283
+ providerWebTools.push(builtin);
1284
+ }
1285
+ }
926
1286
  let wantSearch = false;
927
1287
  let wantHistory = false;
928
1288
  let wantDesks = false;
1289
+ let wantAsk = false;
929
1290
  for (const ref of toolRefs(front)) {
930
1291
  const toolName = ref.name;
931
1292
  if (ref.mode === "ask") {
932
1293
  disabled.push(toolName);
933
1294
  continue;
934
1295
  }
1296
+ // `web` is our own tool, granted with the rest of them above. A model
1297
+ // provider answering search or fetch on its own side (`web: {search:
1298
+ // zai}`) adds that provider's tool beside it; the web tool's search then
1299
+ // names it (FOLDRUN_WEB_BUILTIN) rather than asking our engine.
1300
+ if (toolName === "web" && !ref.linked) {
1301
+ allowed.push(...providerWebTools);
1302
+ continue;
1303
+ }
935
1304
  // Resolution order: built-in group, exact SDK name, then your own tools.
936
1305
  // Built-ins win a bare name, because their names are a small fixed set —
937
1306
  // but a tool hidden by one is worth saying out loud rather than silently
@@ -945,13 +1314,15 @@ identity = {}) {
945
1314
  if (available[toolName])
946
1315
  shadowed.push(toolName);
947
1316
  }
948
- else if (toolName === "search" || toolName === "history" || toolName === "desks") {
1317
+ else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
949
1318
  // The platform's own groups, served in-process — built below, once
950
1319
  // the roots and the records are known.
951
1320
  if (toolName === "search")
952
1321
  wantSearch = true;
953
1322
  else if (toolName === "history")
954
1323
  wantHistory = true;
1324
+ else if (toolName === "ask")
1325
+ wantAsk = true;
955
1326
  else
956
1327
  wantDesks = true;
957
1328
  if (available[toolName])
@@ -979,10 +1350,10 @@ identity = {}) {
979
1350
  ? [
980
1351
  { label: "knowledge/", dir: path.join(agentDir, "knowledge") },
981
1352
  { label: "memory/", dir: path.join(agentDir, "memory") },
982
- { label: "../../knowledge/", dir: path.join(wsRoot, "knowledge") },
983
- { label: "../../memory/", dir: path.join(wsRoot, "memory") },
984
- { label: "../../state/", dir: path.join(wsRoot, "state") },
985
- { label: `../../${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
1353
+ { label: "workspace/knowledge/", dir: path.join(wsRoot, "knowledge") },
1354
+ { label: "workspace/memory/", dir: path.join(wsRoot, "memory") },
1355
+ { label: "workspace/state/", dir: path.join(wsRoot, "state") },
1356
+ { label: `workspace/${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
986
1357
  { label: "outputs/", dir: path.join(agentDir, "outputs") },
987
1358
  { label: "[library] knowledge/", dir: libraryDir(tenant, "knowledge") },
988
1359
  { label: "[library] memory/", dir: libraryDir(tenant, "memory") },
@@ -1004,6 +1375,14 @@ identity = {}) {
1004
1375
  const historyTools = wantHistory ? buildHistoryTools(historyDigest) : { server: null, toolNames: [], promptLines: [] };
1005
1376
  const deskTools = wantDesks ? buildDeskTools(deskDigest) : { server: null, toolNames: [], promptLines: [] };
1006
1377
  allowed.push(...searchTools.toolNames, ...historyTools.toolNames, ...deskTools.toolNames);
1378
+ // tools: [ask] — ask_person. Its server is built where the step runs (it
1379
+ // needs the step's emit, and in a pod the proxy's address); here only the
1380
+ // grant, the wait and the prompt's line.
1381
+ const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
1382
+ if (ask) {
1383
+ allowed.push(ASK_TOOL);
1384
+ parts.push(`# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
1385
+ }
1007
1386
  if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
1008
1387
  parts.push(`# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
1009
1388
  }
@@ -1023,12 +1402,16 @@ identity = {}) {
1023
1402
  return {
1024
1403
  front,
1025
1404
  clockEnv,
1405
+ /** The zone, where it came from, and anything unreadable on the way —
1406
+ * reported by the step, not here, so it lands in the run's trace. */
1407
+ clock,
1026
1408
  systemPrompt: parts.join("\n\n"),
1027
1409
  allowed: finalAllowed,
1028
1410
  disabled,
1029
1411
  unknownTools,
1030
1412
  shadowed,
1031
1413
  legacyUse,
1414
+ retiredTools: retiredToolNames(front),
1032
1415
  mcpServers,
1033
1416
  mcpNames,
1034
1417
  apiTools,
@@ -1038,19 +1421,21 @@ identity = {}) {
1038
1421
  // than using the servers built here.
1039
1422
  apiSpecs: apis,
1040
1423
  scriptSpecs,
1424
+ outward: outward || scriptSpecs.some((sc) => sc.outward),
1041
1425
  searchRoots,
1042
1426
  historyDigest,
1043
1427
  deskDigest,
1044
1428
  searchTools,
1045
1429
  historyTools,
1046
1430
  deskTools,
1431
+ ask,
1047
1432
  // `spec` is the MERGED declaration — the agent's own block or its
1048
1433
  // workspace's, plus every granted tool's. The isolated path builds its
1049
1434
  // runtime from this; passing the agent's frontmatter alone silently drops
1050
1435
  // what the tools declared, and a tool whose program needs a package it
1051
1436
  // asked for by name fails inside the container with "cannot find" while
1052
1437
  // the host path, which uses this same merged spec, works fine.
1053
- runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec },
1438
+ runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec, dispose: runtime.dispose },
1054
1439
  secretEnv: { ...runtime.env, ...secretEnv },
1055
1440
  formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
1056
1441
  providerEnv,
@@ -1058,8 +1443,19 @@ identity = {}) {
1058
1443
  providerSecrets,
1059
1444
  providerWarnings,
1060
1445
  fallbackEnv,
1061
- translator,
1062
- fallbackTranslator,
1446
+ region,
1447
+ // Who answers web search and fetch, resolved once up top. The step
1448
+ // needs them again to grant a direct API's key to its host.
1449
+ searchChoice,
1450
+ fetchChoice,
1451
+ actionChoices,
1452
+ web,
1453
+ language,
1454
+ // Stamped with the step's own zone: when one of these refuses, the
1455
+ // reset time it reports is told in the calendar the step works to,
1456
+ // not in the pod's UTC.
1457
+ translator: translator && { ...translator, timezone },
1458
+ fallbackTranslator: fallbackTranslator && { ...fallbackTranslator, timezone },
1063
1459
  apiWarnings,
1064
1460
  secretScopes,
1065
1461
  missingTools,
@@ -1088,9 +1484,9 @@ identity = {}) {
1088
1484
  * Never fails a step: a link is bookkeeping, and a run that did its work must
1089
1485
  * not be marked failed because a URL could not be minted.
1090
1486
  */
1091
- function publishPublicDir(tenant, workspace, push) {
1487
+ async function publishPublicDir(tenant, workspace, push) {
1092
1488
  try {
1093
- const { added } = platform.syncPublicShares(tenant, workspace);
1489
+ const { added } = await platform.syncPublicShares(tenant, workspace);
1094
1490
  if (added.length)
1095
1491
  push("info", `shared: ${added.join(", ")}`);
1096
1492
  }
@@ -1098,10 +1494,18 @@ function publishPublicDir(tenant, workspace, push) {
1098
1494
  push("error", `shares: ${err instanceof Error ? err.message : String(err)}`);
1099
1495
  }
1100
1496
  }
1101
- function repriced(catalog, model, usage, sdkCost, push) {
1497
+ export function repriced(catalog, model, usage, sdkCost, push,
1498
+ /** The step's turns each priced at its own model — set when a sub-agent
1499
+ * ran on a different model (ExecOutcome.turnsCostUsd). The lump `usage`
1500
+ * priced at the step's model would bill those turns at the wrong rate. */
1501
+ turnsCostUsd) {
1102
1502
  const known = usage ? catalogCost(catalog, model, usage) : null;
1103
1503
  if (known === null)
1104
1504
  return sdkCost;
1505
+ if (typeof turnsCostUsd === "number") {
1506
+ push("info", `cost from each model turn at its own model's rate: $${turnsCostUsd.toFixed(6)} (sub-agents ran on other models)`);
1507
+ return turnsCostUsd;
1508
+ }
1105
1509
  if (sdkCost !== null && Math.abs(known - sdkCost) > 0.000001) {
1106
1510
  push("info", `cost repriced from the gateway's catalogue: $${known.toFixed(6)} (sdk said $${sdkCost.toFixed(6)})`);
1107
1511
  }
@@ -1117,7 +1521,9 @@ function repriced(catalog, model, usage, sdkCost, push) {
1117
1521
  * step used to carry only its last attempt's cost, so a `retry: 2` step
1118
1522
  * could spend three shares while reporting one.
1119
1523
  */
1120
- export function recordAttempt(step, n, status, startedAt, eventsBefore,
1524
+ export function recordAttempt(step,
1525
+ /** The step's attempt (`attempts`) these rows belong to. */
1526
+ attempt, status, startedAt, eventsBefore,
1121
1527
  /** The attempt re-attached to a sandbox a previous driver left: same
1122
1528
  * attempt, so its row is replaced rather than repeated. */
1123
1529
  reattach = false) {
@@ -1126,23 +1532,74 @@ reattach = false) {
1126
1532
  .slice(eventsBefore)
1127
1533
  .filter((e) => e.type === "error")
1128
1534
  .at(-1)?.text;
1535
+ // Re-attached: the same attempt, so its rows — the last one, and the lost
1536
+ // slim go before it — are replaced, not repeated.
1537
+ const kept = [...tries];
1538
+ const attemptOf = (t) => t.attempt ?? t.n;
1539
+ if (reattach && kept.length && attemptOf(kept.at(-1)) === attempt) {
1540
+ kept.pop();
1541
+ if (kept.length && attemptOf(kept.at(-1)) === attempt && kept.at(-1).status === "lost")
1542
+ kept.pop();
1543
+ }
1544
+ // Tries are numbered in the order they ran: a full re-run after a lost
1545
+ // slim go is the next try, not the same number again (live, 2026-10-01,
1546
+ // both rows read n: 1). `attempt` says which attempt a row belongs to
1547
+ // when its number no longer does.
1548
+ let next = (kept.at(-1)?.n ?? 0) + 1;
1549
+ const numbered = () => {
1550
+ const n = next++;
1551
+ return n === attempt ? { n } : { n, attempt };
1552
+ };
1553
+ // A slim go the browser pod was lost under, before this attempt re-ran
1554
+ // on the full image: its own row, first (runStep set it aside).
1555
+ const lost = step.podLostTry ? [{ ...numbered(), ...step.podLostTry, startedAt }] : [];
1129
1556
  const row = {
1130
- n,
1557
+ ...numbered(),
1131
1558
  status,
1132
1559
  costUsd: step.costUsd,
1133
1560
  tokens: step.tokens ?? null,
1134
1561
  computeSecs: step.computeSecs ?? null,
1135
- startedAt,
1562
+ startedAt: step.podLostTry?.finishedAt ?? startedAt,
1136
1563
  finishedAt: step.finishedAt ?? new Date().toISOString(),
1137
1564
  ...(status === "failed" && lastError ? { error: lastError.slice(0, 500) } : {}),
1565
+ ...(step.image?.variant ? { image: step.image.variant } : {}),
1566
+ ...(step.browserPod ? { browserPod: browserPodLine(step.browserPod) } : {}),
1138
1567
  };
1139
- const kept = reattach && tries.at(-1)?.n === n ? tries.slice(0, -1) : tries;
1140
- step.tries = [...kept, row];
1568
+ delete step.podLostTry;
1569
+ step.tries = [...kept, ...lost, row];
1141
1570
  const totals = sumAttempts(step.tries);
1142
1571
  step.costUsd = totals.costUsd;
1143
1572
  step.tokens = totals.tokens;
1144
1573
  step.computeSecs = totals.computeSecs;
1145
1574
  }
1575
+ /** A question an agent asked mid-step goes where approvals go (the
1576
+ * workspace's notify:, `awaiting-approval` events), with the way to answer. */
1577
+ async function notifyQuestion(tenant, workspace, runId, agent, question, options) {
1578
+ const origin = (process.env.FOLDRUN_PUBLIC_URL ?? "").replace(/\/+$/, "");
1579
+ const where = origin ? `${origin}/dashboard/${encodeURIComponent(workspace)}/runs/${encodeURIComponent(runId)}${tenant !== "default" ? `?tenant=${encodeURIComponent(tenant)}` : ""}` : `run ${runId}`;
1580
+ await sendPlainNotification(tenant, workspace, {
1581
+ event: "awaiting-approval",
1582
+ headline: `${agent} is asking you something`,
1583
+ detail: `${question}${options?.length ? `\n\nOptions: ${options.join(" · ")}` : ""}\n\nAnswer it on ${where}, or \`foldrun answer ${runId} "…"\`.`,
1584
+ runId,
1585
+ }).catch(() => false);
1586
+ }
1587
+ /** The local stand-in for a person: the terminal, when there is one. */
1588
+ async function terminalAsk(question, options) {
1589
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1590
+ return null;
1591
+ const { createInterface } = await import("node:readline/promises");
1592
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1593
+ try {
1594
+ const list = options?.length ? `\n${options.map((o, i) => ` ${i + 1}. ${o}`).join("\n")}\n` : "";
1595
+ const raw = (await rl.question(`\n the agent asks: ${question}${list}\n your answer: `)).trim();
1596
+ const n = Number(raw);
1597
+ return options?.length && Number.isInteger(n) && n >= 1 && n <= options.length ? options[n - 1] : raw || null;
1598
+ }
1599
+ finally {
1600
+ rl.close();
1601
+ }
1602
+ }
1146
1603
  async function runStep(agentDir, tenant, step, context, save, modelOverride, tags = [], effortOverride, runId,
1147
1604
  /** What the previous group returned as DATA (an `output: json` step's
1148
1605
  * value), beside the prose in `context`. Undefined when nothing did. */
@@ -1159,7 +1616,14 @@ testRun = false,
1159
1616
  /** Has a person asked for the run to stop? Read on a clock by the
1160
1617
  * in-process model loop, so a stop lands mid-step and not at the next
1161
1618
  * group. The isolated path needs none: stopRun destroys the sandbox. */
1162
- stopRequested) {
1619
+ stopRequested,
1620
+ /** The flow's `timezone:`, for the clock cascade. Undefined for a step
1621
+ * run outside any flow, which still gets agent → workspace → account. */
1622
+ flowTimezone,
1623
+ /** The step's index in run.steps, carried on its egress lease so a
1624
+ * person's message to this step reaches this step's own inbox and not a
1625
+ * sibling's running in the same group. Undefined outside a flow. */
1626
+ stepIndex) {
1163
1627
  // Secret values are injected into scripts as environment variables and
1164
1628
  // substituted into API headers, so a model that reads one back — from a
1165
1629
  // script that prints it, an API error that echoes it, or its own `env` —
@@ -1177,6 +1641,11 @@ stopRequested) {
1177
1641
  // cover a script's own stdout.
1178
1642
  let redactions = [];
1179
1643
  let fileDir = null;
1644
+ let disposeRuntime;
1645
+ // Can this step's tools act outside the workspace? Set once its context is
1646
+ // read; the verdict at the end of this function reads it.
1647
+ let canActOutward = false;
1648
+ const eventsAtStart = step.events.length;
1180
1649
  const redact = (text) => {
1181
1650
  let out = text;
1182
1651
  for (const [value, name] of redactions)
@@ -1200,7 +1669,16 @@ stopRequested) {
1200
1669
  }
1201
1670
  };
1202
1671
  const push = (type, text, extra) => {
1203
- step.events.push({ t: new Date().toISOString(), type, text: redact(text), ...extra });
1672
+ const t = new Date().toISOString();
1673
+ step.events.push({ t, type, text: redact(text), ...extra });
1674
+ // A person in the loop: the question, its answer, a message delivered —
1675
+ // onto the step, so the run page, the API and history show them.
1676
+ if (extra?.operator) {
1677
+ applyOperatorEvent(step, extra.operator, t);
1678
+ if (extra.operator.kind === "asked" && runId && !testRun) {
1679
+ void notifyQuestion(tenant, path.basename(path.resolve(agentDir, "..", "..")), runId, step.agent, extra.operator.question, extra.operator.options);
1680
+ }
1681
+ }
1204
1682
  save();
1205
1683
  };
1206
1684
  // The clock on the step itself. `??=` so a retry extends the first
@@ -1218,7 +1696,9 @@ stopRequested) {
1218
1696
  ? fs.readFileSync(stubFile, "utf8").split(/\n---\n/)
1219
1697
  : [`stub result from ${step.agent}`];
1220
1698
  const call = step.events.filter((e) => e.text.startsWith("stub call")).length;
1221
- push("info", `stub call ${call + 1}`);
1699
+ // The ceiling it was given rides on the line, so the budget arithmetic
1700
+ // around fan-out is testable without a model to stop.
1701
+ push("info", `stub call ${call + 1}${stepBudgetUsd !== null ? ` (ceiling $${stepBudgetUsd.toFixed(2)})` : ""}`);
1222
1702
  step.status = "completed";
1223
1703
  // A stub answer may open with `cost: 0.25` — a pretend price, so the
1224
1704
  // parts of orchestration that watch money (budget:) are testable too.
@@ -1261,7 +1741,9 @@ stopRequested) {
1261
1741
  // step: the generic HTTP tool still works without the typed ones.
1262
1742
  for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
1263
1743
  push("error", `openapi: ${w}`);
1264
- const { front, clockEnv, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, translator, fallbackTranslator, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent });
1744
+ const { front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
1745
+ disposeRuntime = runtime.dispose;
1746
+ canActOutward = outward;
1265
1747
  // A retry that moved the step up a class (an evicted or OOM-killed
1266
1748
  // attempt) wins over the agent's own `size:` for the attempts after.
1267
1749
  const size = step.sizeUp ?? agentSize;
@@ -1284,18 +1766,41 @@ stopRequested) {
1284
1766
  .map(([name, value]) => [value, name])
1285
1767
  // Longest first, so a value containing another is replaced whole.
1286
1768
  .sort((a, b) => b[0].length - a[0].length);
1769
+ // One line for the clock: which zone this step works to, and which level
1770
+ // set it. "Why is this dated yesterday" should be answerable from the run.
1771
+ push("info", `clock: ${clock.timezone} (${clock.source}) — today is ${clockEnv.FOLDRUN_DATE}`);
1772
+ // And one more only when something written could not be read as a zone.
1773
+ for (const problem of clock.problems)
1774
+ push("error", problem);
1287
1775
  if (providerLabel)
1288
1776
  push("info", `provider: ${providerLabel}`);
1777
+ // Which credential the model is called with, on the record: an agent
1778
+ // that names a provider uses its own; otherwise the platform's (in a
1779
+ // run container) or this process's — never a login that just happens
1780
+ // to be on the machine (model-credential.ts).
1781
+ const credential = providerLabel ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
1782
+ step.credential = credential ? credential.label : `provider: ${providerLabel}`;
1783
+ if (credential) {
1784
+ const line = credentialLine(credential);
1785
+ push(line.type, line.text);
1786
+ }
1289
1787
  for (const w of providerWarnings)
1290
1788
  push("error", w);
1789
+ // A level that wrote a language nobody can read was skipped, not obeyed.
1790
+ for (const l of language.lines)
1791
+ push("info", l);
1792
+ for (const l of region.lines)
1793
+ push("info", l);
1291
1794
  for (const w of apiWarnings)
1292
1795
  push("error", `openapi: ${w}`);
1293
1796
  if (formatWarning)
1294
1797
  push("error", formatWarning);
1295
1798
  if (legacyUse.length)
1296
1799
  push("error", legacyUseError(legacyUse));
1800
+ for (const t of retiredTools)
1801
+ push("error", retiredToolError(t));
1297
1802
  for (const t of unknownTools) {
1298
- push("error", `tools: "${t}" is not a tool group (web, fetch, read, files, bash, search, history), an SDK tool name, or a tool in this workspace's library — nothing was granted for it`);
1803
+ push("error", `tools: "${t}" is not a tool group (read, write, code, search, history, desks), an SDK tool name, or a tool in this workspace's library — nothing was granted for it`);
1299
1804
  }
1300
1805
  for (const name of mcpNames)
1301
1806
  push("info", `mcp server connected: ${name}`);
@@ -1404,9 +1909,11 @@ stopRequested) {
1404
1909
  fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
1405
1910
  // Expose the workspace's shared scripts/ as ./shared so every agent reaches
1406
1911
  // them by the same path, without each one carrying a copy.
1407
- // No symlinks into the agent directory — they would recurse into the
1408
- // workspace's own file listing. Shared paths are resolved by the script
1409
- // tool layer and described to the agent below.
1912
+ // No permanent symlinks into the agent directory — they would recurse
1913
+ // into the workspace's own file listing. Shared paths are resolved by the
1914
+ // script tool layer and described to the agent below; the one link there
1915
+ // is, `workspace` → the root for the shell, lives only for the length of
1916
+ // a step (executeStep, confine.ts#linkWorkspace).
1410
1917
  // The confinement boundary: agents in a workspace are one team, but the
1411
1918
  // workspace is where isolation is enforced.
1412
1919
  const workspaceRoot = path.resolve(agentDir, "..", "..");
@@ -1493,14 +2000,40 @@ stopRequested) {
1493
2000
  for (const m of missingConsults) {
1494
2001
  push("error", `agents: "${m}" is not an agent in this workspace — no consult tool granted`);
1495
2002
  }
1496
- const consultNames = consults.length ? [...mcpNames, "foldrun_agents"] : mcpNames;
2003
+ const consultNames = [
2004
+ ...mcpNames,
2005
+ ...(consults.length ? ["foldrun_agents"] : []),
2006
+ ...(ask ? ["foldrun_ask"] : []),
2007
+ ];
2008
+ // Colleagues this agent may DELEGATE to: each with its own context and
2009
+ // its own tools — resolved exactly as its own step's would be, then cut
2010
+ // to what THIS step holds (subagents.ts). Built host-side, like consults.
2011
+ const subagents = buildSubagentSpecs({
2012
+ names: front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
2013
+ parentAllowed: [...allowed, ...consultNames.map((n) => `mcp__${n}`)], push,
2014
+ });
2015
+ if (subagents.length) {
2016
+ // The delegation tool itself, granted only when there is someone to
2017
+ // delegate to.
2018
+ allowed.push(DELEGATE_TOOL);
2019
+ for (const s of subagents)
2020
+ push("info", `subagent: ${s.name} — ${s.tools.length ? s.tools.join(", ") : "no tools"}`);
2021
+ }
1497
2022
  // The last provider refusal this step saw, for the fallback decision —
1498
2023
  // watched on both paths, because a refusal reads the same from a pod
1499
2024
  // and from this process.
1500
2025
  let lastRefusal = "";
2026
+ // And WHY it refused, in words, when anything on the way saw the
2027
+ // headers that say so: the translator here, the egress proxy over
2028
+ // there. Kept apart from `lastRefusal` — that one decides whether to
2029
+ // fall back, this one is what a person reads afterwards.
2030
+ let refusalWhy = "";
1501
2031
  const pushWatching = (type, text, extra) => {
1502
2032
  if (type === "error" && isProviderRefusal(text))
1503
2033
  lastRefusal = text;
2034
+ const why = refusalFromLine(text);
2035
+ if (why)
2036
+ refusalWhy = why;
1504
2037
  push(type, text, extra);
1505
2038
  };
1506
2039
  // The second supply: the block's own fallback when it declared one (a
@@ -1508,6 +2041,28 @@ stopRequested) {
1508
2041
  // fallback), else the platform's, and only for a step riding the
1509
2042
  // platform credential.
1510
2043
  const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
2044
+ // Where this step stands on a second supply, for the refusal sentence.
2045
+ // It starts as the truth before anything has run and becomes
2046
+ // "exhausted" the moment the fallback has had its turn — the owner is
2047
+ // about to remove the OpenRouter fallback, so "none" becomes the normal
2048
+ // reading and has to look like a decision on the page.
2049
+ let supplyState = secondSupply ? "trying" : "none";
2050
+ // The line a person reads when this step ends failed: the reason, if
2051
+ // anything on the way learned one, else the status put into words, and
2052
+ // in every case where the step could go next. Pushed last so it is the
2053
+ // last error event — which is what runFailure, the desks' headlines and
2054
+ // `foldrun report` read.
2055
+ const sayWhy = (status) => {
2056
+ if (status !== "failed" || !lastRefusal)
2057
+ return;
2058
+ const said = Number(lastRefusal.match(/API Error: (\d{3})/)?.[1] ?? NaN);
2059
+ const why = refusalWhy ||
2060
+ explainRefusal({
2061
+ status: Number.isFinite(said) && isRefusalStatus(said) ? said : 402,
2062
+ timezone: clock.timezone,
2063
+ });
2064
+ push("error", `${why}${supplyNote(supplyState)}`);
2065
+ };
1511
2066
  // `schema:` — inline on the step, or a file under the workspace read
1512
2067
  // now, host-side, so both executors are handed the same document. A
1513
2068
  // file that cannot be read is a failed step before any model runs:
@@ -1518,6 +2073,19 @@ stopRequested) {
1518
2073
  }
1519
2074
  if (step.maxTurns)
1520
2075
  push("info", `max_turns: ${step.maxTurns}`);
2076
+ // `limits:` — account, workspace, agent, then this step's option;
2077
+ // nearest wins per key. Enforced in the model loop's hook, on either
2078
+ // path (step-exec.ts), so it is resolved here as plain values.
2079
+ const stepLimits = cascadeLimits([
2080
+ readAgentsMd(accountDir(tenant))?.data?.limits,
2081
+ readAgentsMd(workspaceRootOf(agentDir))?.data?.limits,
2082
+ front.limits,
2083
+ step.limits,
2084
+ ]);
2085
+ const limitOwners = toolOwners(apiTools.owners, scriptSpecs);
2086
+ const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
2087
+ if (limitOpts.limits)
2088
+ push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
1521
2089
  const isolation = process.env.FOLDRUN_RUN_ISOLATION;
1522
2090
  if (isolatedRun()) {
1523
2091
  // The isolated path: the whole loop — model, built-in tools, scripts —
@@ -1540,7 +2108,19 @@ stopRequested) {
1540
2108
  // before anything is copied in. Null where no proxy is configured
1541
2109
  // (the CLI, a compose install without FOLDRUN_EGRESS_URL), and then
1542
2110
  // everything below materialises exactly as it always did.
1543
- const grant = { secrets: {} };
2111
+ const grant = { secrets: {}, timezone: clock.timezone };
2112
+ // A direct search API's key: granted to that API's host and no other,
2113
+ // exactly as an http tool's key is granted to its base URL's host. The
2114
+ // script sends the placeholder; the worker fills it on the way out.
2115
+ for (const choice of [searchChoice, fetchChoice]) {
2116
+ if (choice.shape === "direct" && choice.secret && choice.host && choice.secret in liveSecrets) {
2117
+ addGrant(grant, choice.secret, liveSecrets[choice.secret], choice.host);
2118
+ }
2119
+ }
2120
+ for (const c of actionChoices) {
2121
+ if (c.provider && c.secret in liveSecrets)
2122
+ addGrant(grant, c.secret, liveSecrets[c.secret], c.host);
2123
+ }
1544
2124
  for (const api of apiSpecs) {
1545
2125
  const host = hostOf(api.base);
1546
2126
  if (!host)
@@ -1556,13 +2136,16 @@ stopRequested) {
1556
2136
  }
1557
2137
  }
1558
2138
  }
1559
- const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun });
2139
+ const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
1560
2140
  // Which secrets must still be real inside the sandbox: a script tool
1561
2141
  // reads them from its environment, and unless it says `secrets:
1562
2142
  // proxied` it needs the value, not the name. bash is a script by
1563
2143
  // another route. The record says which way it went, and why.
1564
2144
  const materialisers = [
1565
2145
  ...scriptSpecs.filter((sc) => sc.secrets !== "proxied").map((sc) => sc.name),
2146
+ // web proxies its keys — unless it may browse: a remote browser's key
2147
+ // goes into a websocket, and cookie and login secrets into the page.
2148
+ ...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
1566
2149
  ...(allowed.includes("Bash") ? ["bash"] : []),
1567
2150
  ];
1568
2151
  const materialise = !lease || materialisers.length > 0;
@@ -1634,7 +2217,7 @@ stopRequested) {
1634
2217
  libraryRoot: libraryDir(tenant),
1635
2218
  galleryRoot: platform.galleryDir() ?? undefined,
1636
2219
  // Keys this step's dependency cache. Per-account by design — see
1637
- // run-container.ts#runtimeCacheDir for why it is not shared.
2220
+ // run-container.ts#runtimeCacheMount for why it is not shared.
1638
2221
  tenant,
1639
2222
  input: {
1640
2223
  agentRel: path.relative(workspaceRoot, agentDir).replaceAll("\\", "/"),
@@ -1651,6 +2234,7 @@ stopRequested) {
1651
2234
  // field. A tool's own `runtime:` is part of what this step needs.
1652
2235
  runtime: runtime.spec,
1653
2236
  consults,
2237
+ ...(subagents.length ? { subagents } : {}),
1654
2238
  timeoutSec: step.timeout,
1655
2239
  budgetUsd: stepBudgetUsd,
1656
2240
  budgetNote: stepBudgetNote,
@@ -1659,7 +2243,12 @@ stopRequested) {
1659
2243
  output: step.output,
1660
2244
  schema: resolvedSchema ?? undefined,
1661
2245
  maxTurns: step.maxTurns,
2246
+ ...limitOpts,
1662
2247
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
2248
+ // Outward steps keep a browser of their own: an executor that
2249
+ // could browse from the slim image through the account's pod
2250
+ // leaves them on the full one (browser-pod.ts).
2251
+ ...(outward ? { outward: true } : {}),
1663
2252
  // The container sees the workspace at /workspace and the library
1664
2253
  // at /library; the roots are named host-side and moved here.
1665
2254
  search: searchRoots.map((r) => ({
@@ -1670,6 +2259,7 @@ stopRequested) {
1670
2259
  })),
1671
2260
  history: historyDigest,
1672
2261
  desks: deskDigest,
2262
+ ...(ask ? { ask } : {}),
1673
2263
  },
1674
2264
  env: Object.fromEntries(Object.entries({
1675
2265
  // The platform's own model credential, when the agent names no
@@ -1719,8 +2309,39 @@ stopRequested) {
1719
2309
  // attempt holds its own sandbox, so the meter owes the sum.
1720
2310
  const firstArgs = isolatedArgs(platformModelEnv());
1721
2311
  await lease?.commit();
1722
- let outcome = await runIsolated(firstArgs);
2312
+ let credentialUsed = platformModelCredential();
2313
+ // The arguments of the attempt whose outcome is current — what a
2314
+ // re-run on the full image repeats, on the same model supply.
2315
+ let lastArgs = firstArgs;
2316
+ const runTracked = (a) => {
2317
+ lastArgs = a;
2318
+ return runIsolated(a);
2319
+ };
2320
+ let outcome = await runTracked(firstArgs);
1723
2321
  step.sandbox = null; // whatever happens next starts its own
2322
+ // A 401 from a token that rotated mid-step is not a broken key: the
2323
+ // replacement is already on its way to the mounted file. Wait for it
2324
+ // once, then fall into the same retry the busy case uses.
2325
+ if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
2326
+ push("info", `the model credential was refused mid-step — waiting for the refreshed one`);
2327
+ const rotated = await awaitRotatedCredential(credentialUsed);
2328
+ if (rotated) {
2329
+ push("info", "the credential rotated under this step; retrying on the new one");
2330
+ credentialUsed = rotated;
2331
+ lastRefusal = "";
2332
+ const previous = outcome.timing;
2333
+ // Build first: isolatedArgs is what re-grants the model key, and
2334
+ // commit() is what publishes the grant to Redis, where the proxy
2335
+ // reads it. Committing first sent the revoked token again — every
2336
+ // rotation retry on 2026-09-22/23 401'd this way.
2337
+ const retry = isolatedArgs(platformModelEnv());
2338
+ await lease?.commit();
2339
+ outcome = withEarlierTiming(await runTracked(retry), previous);
2340
+ }
2341
+ else {
2342
+ push("info", "the credential did not change — this is the key itself, not a rotation");
2343
+ }
2344
+ }
1724
2345
  for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
1725
2346
  const wait = backoffMs(n, lastRefusal);
1726
2347
  push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
@@ -1731,7 +2352,7 @@ stopRequested) {
1731
2352
  const previous = outcome.timing;
1732
2353
  const again = isolatedArgs(platformModelEnv());
1733
2354
  await lease?.commit();
1734
- outcome = await runIsolated(again);
2355
+ outcome = await runTracked(again);
1735
2356
  outcome = withEarlierTiming(outcome, previous);
1736
2357
  }
1737
2358
  // The second supply, tried exactly once, and only when the primary
@@ -1754,25 +2375,74 @@ stopRequested) {
1754
2375
  .filter((entry) => typeof entry[1] === "string")),
1755
2376
  };
1756
2377
  await lease?.commit();
1757
- outcome = await runIsolated(fallbackArgs);
2378
+ supplyState = "exhausted";
2379
+ outcome = await runTracked(fallbackArgs);
1758
2380
  // Both attempts held sandboxes; the meter owes the sum. The first
1759
2381
  // try's pod ran, was billed for by the platform, and must not
1760
2382
  // vanish from the record because a second try replaced its outcome.
1761
2383
  outcome = withEarlierTiming(outcome, first);
1762
2384
  }
2385
+ // Slim browsing (browser-pod.ts): the step ran on the slim image and
2386
+ // the account's browser pod went away under it, past the web tool's
2387
+ // reconnects. Reads only — run it again from the start on the full
2388
+ // image, where a browser in the step is always there. Anything
2389
+ // written — fail it, naming what, and leave retry:/on-fail:/a person
2390
+ // to decide; a second go could send or charge twice.
2391
+ step.image = outcome.image ?? null;
2392
+ step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
2393
+ if (outcome.status === "failed" && outcome.browserPod?.lost) {
2394
+ const decision = podLossDecision(outcome.browserPod);
2395
+ if (decision.rerun) {
2396
+ push("info", decision.note);
2397
+ const first = outcome;
2398
+ // The slim go is a try of its own on the record — image slim,
2399
+ // status lost, its own cost, tokens and sandbox seconds — so the
2400
+ // run page and `foldrun report` show two tries, slim (lost) then
2401
+ // full, and the step's totals are the sum of both rows
2402
+ // (recordAttempt). Live, 2026-10-01, the step showed only the
2403
+ // full re-run.
2404
+ step.podLostTry = {
2405
+ status: "lost",
2406
+ costUsd: repriced(catalog, wireModel, first.usage ?? null, first.costUsd, push, first.turnsCostUsd),
2407
+ tokens: first.usage ? { input: first.usage.inputTokens, output: first.usage.outputTokens } : null,
2408
+ computeSecs: first.timing ? Math.round(first.timing.totalMs) / 1000 : null,
2409
+ finishedAt: new Date().toISOString(),
2410
+ error: decision.note.slice(0, 500),
2411
+ image: "slim",
2412
+ browserPod: browserPodLine(first.browserPod),
2413
+ };
2414
+ const again = { ...lastArgs, input: { ...lastArgs.input, image: "full" }, resume: null };
2415
+ await lease?.commit();
2416
+ // Before the call, not after: a re-run that throws (the full
2417
+ // sandbox never started) is still the full go on the record — its
2418
+ // failed row read "slim" when this was set only on success.
2419
+ step.image = { variant: "full", why: "re-run after the browser pod was lost" };
2420
+ step.browserPod = { ...first.browserPod, fallback: RERAN_ON_FULL };
2421
+ outcome = await runTracked(again);
2422
+ step.image = outcome.image ?? step.image;
2423
+ }
2424
+ else {
2425
+ push("error", decision.message);
2426
+ step.browserPod = { ...outcome.browserPod, failure: decision.message };
2427
+ }
2428
+ }
1763
2429
  if (lease) {
1764
2430
  for (const line of await lease.drainLog()) {
2431
+ // pushWatching, not push: a line the proxy wrote may carry the
2432
+ // refusal mark, and that sentence is the whole reason the proxy
2433
+ // reads response headers at all.
1765
2434
  if (typeof line === "string")
1766
- push("info", line);
2435
+ pushWatching("info", line);
1767
2436
  else
1768
- push("info", line.text, { effect: line.effect });
2437
+ pushWatching("info", line.text, { effect: line.effect });
1769
2438
  }
1770
2439
  await lease.release();
1771
2440
  }
1772
2441
  // Nothing a test run left in storage/public/ reached storage/, so
1773
2442
  // there is nothing to publish — and a share link is an outward act.
1774
2443
  if (!testRun)
1775
- publishPublicDir(tenant, path.basename(workspaceRoot), push);
2444
+ await publishPublicDir(tenant, path.basename(workspaceRoot), push);
2445
+ sayWhy(outcome.status);
1776
2446
  step.status = outcome.status;
1777
2447
  step.result = redactText(outcome.result);
1778
2448
  step.conclusion = redactText(outcome.conclusion);
@@ -1783,7 +2453,7 @@ stopRequested) {
1783
2453
  push("error", `sandbox ended: ${outcome.reason}`);
1784
2454
  if (outcome.data !== undefined)
1785
2455
  step.data = redactData(outcome.data);
1786
- step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
2456
+ step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push, outcome.turnsCostUsd);
1787
2457
  step.tokens = outcome.usage
1788
2458
  ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
1789
2459
  : null;
@@ -1826,7 +2496,14 @@ stopRequested) {
1826
2496
  push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
1827
2497
  }
1828
2498
  const stepSecrets = held.env;
1829
- const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...providerEnv }, (type, text) => push(type, text));
2499
+ // The model credential for this machine's Claude Code — the agent's
2500
+ // own provider when it names one. The host allowlist drops it, so
2501
+ // without this every in-process step ran on the machine's login.
2502
+ const localModelEnv = Object.keys(providerEnv).length ? providerEnv : resolveModelCredential().env;
2503
+ const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...localModelEnv }, (type, text) => push(type, text));
2504
+ // On this machine nobody can write in and there is no proxy: the
2505
+ // question goes to the terminal when there is one, else unanswered.
2506
+ const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
1830
2507
  // @file secrets become 0600 paths here (host run), cleaned up in the
1831
2508
  // finally — once, however many attempts follow.
1832
2509
  const mat = materializeFileSecrets(agentDir, stepSecrets);
@@ -1835,6 +2512,7 @@ stopRequested) {
1835
2512
  agentDir,
1836
2513
  workspaceRoot,
1837
2514
  libraryRoot: libraryDir(tenant),
2515
+ ...(subagents.length ? { subagents } : {}),
1838
2516
  prompt,
1839
2517
  model,
1840
2518
  effort,
@@ -1848,6 +2526,7 @@ stopRequested) {
1848
2526
  ...(searchTools.server ? { foldrun_search: searchTools.server } : {}),
1849
2527
  ...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
1850
2528
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
2529
+ ...(askTools ? { foldrun_ask: askTools.server } : {}),
1851
2530
  ...mcpServers,
1852
2531
  },
1853
2532
  // Declared secrets reach the agent's scripts as env vars; the model
@@ -1864,6 +2543,7 @@ stopRequested) {
1864
2543
  output: step.output,
1865
2544
  schema: resolvedSchema ?? undefined,
1866
2545
  maxTurns: step.maxTurns,
2546
+ ...limitOpts,
1867
2547
  stopRequested,
1868
2548
  // What the scripts saw, the verify sees: a flow can then check that
1869
2549
  // the proof a step left names THIS run, not one that came before —
@@ -1887,6 +2567,9 @@ stopRequested) {
1887
2567
  // under the run and the originals come back.
1888
2568
  const attemptThrough = async (modelEnv, spec) => {
1889
2569
  const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
2570
+ // What earlier steps of this test run wrote, laid over the real files
2571
+ // for this attempt; restoreDivertedDirs puts the originals back.
2572
+ const handed = before && runId && overlayTestWrites(workspaceRoot, divertFor(runId).to).length ? snapshotDivertedDirs(workspaceRoot) : before;
1890
2573
  const t = spec ? await startTranslator(spec) : null;
1891
2574
  try {
1892
2575
  return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
@@ -1894,16 +2577,32 @@ stopRequested) {
1894
2577
  finally {
1895
2578
  if (t) {
1896
2579
  for (const line of t.drainLog())
1897
- push("info", line.startsWith("translator") ? line : `translator: ${line}`);
2580
+ pushWatching("info", line.startsWith("translator") ? line : `translator: ${line}`);
1898
2581
  await t.close();
1899
2582
  }
1900
2583
  if (before && runId) {
1901
2584
  const d = divertFor(runId);
1902
- restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
2585
+ restoreDivertedDirs(workspaceRoot, before, d.to, d.note, handed ?? before);
1903
2586
  }
1904
2587
  }
1905
2588
  };
1906
- let outcome = await attemptThrough(providerEnv, translator);
2589
+ let credentialUsed = platformModelCredential();
2590
+ let outcome = await attemptThrough(localModelEnv, translator);
2591
+ // Same rule as the isolated path: a credential that rotated under the
2592
+ // step is waited out and retried before anything else is considered.
2593
+ if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
2594
+ push("info", `the model credential was refused mid-step — waiting for the refreshed one`);
2595
+ const rotated = await awaitRotatedCredential(credentialUsed);
2596
+ if (rotated) {
2597
+ push("info", "the credential rotated under this step; retrying on the new one");
2598
+ credentialUsed = rotated;
2599
+ lastRefusal = "";
2600
+ outcome = await attemptThrough(localModelEnv, translator);
2601
+ }
2602
+ else {
2603
+ push("info", "the credential did not change — this is the key itself, not a rotation");
2604
+ }
2605
+ }
1907
2606
  // Same rule as the isolated path: a busy provider is waited out on
1908
2607
  // the supply it refused from, before any fallback is considered.
1909
2608
  for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
@@ -1911,16 +2610,18 @@ stopRequested) {
1911
2610
  push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
1912
2611
  await sleep(wait);
1913
2612
  lastRefusal = "";
1914
- outcome = await attemptThrough(providerEnv, translator);
2613
+ outcome = await attemptThrough(localModelEnv, translator);
1915
2614
  }
1916
2615
  // Same rule as the isolated path: one retry, on a refusal, on the
1917
2616
  // declared or platform second supply.
1918
2617
  if (outcome.status === "failed" && lastRefusal && secondSupply) {
1919
2618
  push("info", `primary model supply refused (${lastRefusal.slice(0, 80)}) — retrying this step on the fallback provider`);
2619
+ supplyState = "exhausted";
1920
2620
  outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
1921
2621
  }
1922
2622
  if (!testRun)
1923
- publishPublicDir(tenant, path.basename(workspaceRoot), push);
2623
+ await publishPublicDir(tenant, path.basename(workspaceRoot), push);
2624
+ sayWhy(outcome.status);
1924
2625
  step.status = outcome.status;
1925
2626
  step.result = redactText(outcome.result);
1926
2627
  step.conclusion = redactText(outcome.conclusion);
@@ -1928,7 +2629,7 @@ stopRequested) {
1928
2629
  step.data = redactData(outcome.data);
1929
2630
  // A consult's spend belongs to the step that asked.
1930
2631
  const consultCost = consultTools.drainCost();
1931
- const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
2632
+ const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push, outcome.turnsCostUsd);
1932
2633
  step.costUsd = stepCost === null && consultCost === 0 ? null : (stepCost ?? 0) + consultCost;
1933
2634
  step.tokens = outcome.usage
1934
2635
  ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
@@ -1950,9 +2651,34 @@ stopRequested) {
1950
2651
  catch {
1951
2652
  // best-effort — a leftover scratch file is swept with the run's outputs
1952
2653
  }
2654
+ try {
2655
+ // A private runtime build (see prepareRuntime) is this step's alone.
2656
+ disposeRuntime?.();
2657
+ }
2658
+ catch {
2659
+ // best-effort — it is in the tmpdir
2660
+ }
1953
2661
  }
1954
2662
  step.finishedAt = new Date().toISOString();
1955
2663
  save();
2664
+ return { actedThenFailedCheck: canActOutward && actedThenFailedCheck(step.events.slice(eventsAtStart)) };
2665
+ }
2666
+ /**
2667
+ * Did this attempt call its tools and then fail only on the check after —
2668
+ * `verify:`, `output: json`, a schema? For a step whose tools act outward
2669
+ * that is the one failure a retry must not answer: the email went, the post
2670
+ * is up, and the check failing says nothing about whether they happened. A
2671
+ * second attempt sends them again. Read off the attempt's own events, which
2672
+ * both executors write the same way (step-exec.ts): every event a failed
2673
+ * check writes carries `check: true`. It used to read only the LAST error's
2674
+ * wording, and a failed shell verify or judge writes its detail after its
2675
+ * headline — so the step was retried and the email went twice. The wording
2676
+ * is still honoured, for a driver image older than the tag.
2677
+ */
2678
+ export function actedThenFailedCheck(events) {
2679
+ if (!events.some((e) => e.type === "tool"))
2680
+ return false;
2681
+ return events.some((e) => e.type === "error" && (e.check === true || /^(verify `|output: json — |schema: the value does not fit)/.test(e.text)));
1956
2682
  }
1957
2683
  // Block until a human approves or rejects the pending steps. Polls the run
1958
2684
  // record — the same file the dashboard writes through the approval API — so
@@ -2053,6 +2779,53 @@ function platformFallbackEnv() {
2053
2779
  }
2054
2780
  return out;
2055
2781
  }
2782
+ /**
2783
+ * A 401 the token refresh caused, rather than a key that is simply wrong.
2784
+ *
2785
+ * The refresher on the box turns the long-lived grant into an 8h access
2786
+ * token every four hours, and Anthropic revokes the old one the instant the
2787
+ * new one is minted. The platform reads the credential per step from the
2788
+ * mounted file, which fixed the boot-time copy — but not this: a step
2789
+ * already talking to the API when the rotation lands gets a hard 401
2790
+ * mid-turn, and the replacement does not appear in the mounted file until
2791
+ * the kubelet syncs it, about a minute later.
2792
+ *
2793
+ * That window is narrow and it is not rare. Every 401 on this account in
2794
+ * three weeks of runs fell inside it: 02:07:22 and 02:07:24 against a 02:07
2795
+ * refresh, 22:08:12, 22:08:17, 22:08:30 and 22:09:03 against 22:08. The
2796
+ * desks that lost a run nightly were simply the ones scheduled on the hour
2797
+ * the refresher shares.
2798
+ *
2799
+ * So: on a 401, wait for the file to hold a DIFFERENT credential, then let
2800
+ * the caller retry with it. A token that never changes inside the window is
2801
+ * a key that is actually invalid — no waiting fixes that, and the caller
2802
+ * falls through to the second supply exactly as before. Moving the timer
2803
+ * cannot fix this; a run that takes forty minutes spans any minute chosen.
2804
+ */
2805
+ const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
2806
+ const CREDENTIAL_POLL_MS = 2_000;
2807
+ function isAuthRefusal(text) {
2808
+ return /API Error: 401|access token has (been revoked|expired)/i.test(text);
2809
+ }
2810
+ /**
2811
+ * Wait, briefly, for the mounted credential to become one we have not
2812
+ * already been refused on. Returns the new value, or null when the window
2813
+ * passes without a change — which is the answer that means "this key is
2814
+ * wrong", not "this key is stale".
2815
+ */
2816
+ export async function awaitRotatedCredential(before, now = Date.now, read = () => platformModelCredential(), waitMs = CREDENTIAL_ROTATION_WAIT_MS, pollMs = CREDENTIAL_POLL_MS) {
2817
+ if (!before)
2818
+ return null;
2819
+ const until = now() + waitMs;
2820
+ for (;;) {
2821
+ const current = read();
2822
+ if (current && current !== before)
2823
+ return current;
2824
+ if (now() >= until)
2825
+ return null;
2826
+ await sleep(pollMs);
2827
+ }
2828
+ }
2056
2829
  /** A model-call failure the fallback can answer: the primary refusing over
2057
2830
  * credit, quota or auth — never a failure of the work itself, which would
2058
2831
  * fail identically anywhere. */
@@ -2063,7 +2836,17 @@ function isProviderRefusal(text) {
2063
2836
  // fallback it could only time out on again. A refusal the fallback can
2064
2837
  // answer is about the ACCOUNT; anything about the work or the sandbox is
2065
2838
  // not, however similar the vocabulary.
2066
- return /API Error: (401|402|403|429|5\d\d)|credits?\b|quota|billing|insufficient|overloaded/i.test(text);
2839
+ // A subscription's own ceiling is the same kind of refusal, worded
2840
+ // differently: Claude Code on a Max plan answers "You've hit your weekly
2841
+ // limit · resets 5am" with no status code and none of the money words.
2842
+ // On 2026-09-21 every opus-tier step on the account failed on exactly that
2843
+ // sentence while sonnet steps beside them ran fine — the plan has a
2844
+ // separate weekly cap for Opus — and nothing here recognised it, so a
2845
+ // second supply could not have answered even if one had been configured.
2846
+ // It is not transient (isTransientOverload is right to ignore it: the
2847
+ // window is a week), it is the account's supply being exhausted, which is
2848
+ // precisely what the fallback provider is for.
2849
+ return /API Error: (401|402|403|429|5\d\d)|credits?\b|quota|billing|insufficient|overloaded|(weekly|daily|monthly|usage) limit|rate.?limit(ed)?\b.*resets?\b|limit\b.*\bresets?\b/i.test(text);
2067
2850
  }
2068
2851
  /** How many times a busy provider is given another moment, and the longest
2069
2852
  * any one wait may be. Three is the industry's habit and the point where
@@ -2117,7 +2900,48 @@ export function backoffMs(attempt, text = "", random = Math.random) {
2117
2900
  const base = Math.min(2 ** attempt * 1000, MAX_BACKOFF_MS);
2118
2901
  return Math.round(base * (0.5 + random() * 0.5));
2119
2902
  }
2120
- function platformModelEnv() {
2903
+ /**
2904
+ * The platform's model credential, read at the moment a step needs it.
2905
+ *
2906
+ * It used to come only from this process's environment, which meant the
2907
+ * worker held whatever token it was started with. Refreshing an OAuth token
2908
+ * REVOKES the old one, so a refresh without a restart left every run
2909
+ * answering 401 — on 2026-09-18 that took every desk down for two hours, and
2910
+ * nothing said so until a person read a failed run. The refresher's own
2911
+ * comment had named the fix years of incidents earlier: stop shipping the
2912
+ * token as an env var.
2913
+ *
2914
+ * So: when `FOLDRUN_MODEL_KEY_FILE` names a file (the Kubernetes secret,
2915
+ * mounted rather than injected), it is read fresh every time. The kubelet
2916
+ * updates a mounted secret in place within about a minute of it changing, so
2917
+ * a refreshed token reaches the next step on its own, with no restart, no
2918
+ * timing window, and nothing to skip while work is in flight.
2919
+ *
2920
+ * A missing or empty file falls back to the environment, which is what a
2921
+ * local install, the CLI and every compose setup use.
2922
+ */
2923
+ export function platformModelCredential(env = process.env) {
2924
+ const file = env.FOLDRUN_MODEL_KEY_FILE;
2925
+ if (file) {
2926
+ try {
2927
+ const value = fs.readFileSync(file, "utf8").trim();
2928
+ if (value)
2929
+ return value;
2930
+ }
2931
+ catch {
2932
+ // Unreadable is not the same as absent: say so once, then fall back,
2933
+ // because a running platform that cannot read its credential file is a
2934
+ // thing an operator needs to know about before the next step fails.
2935
+ if (!credentialFileWarned) {
2936
+ credentialFileWarned = true;
2937
+ console.warn(`[model] FOLDRUN_MODEL_KEY_FILE=${file} could not be read; falling back to the environment`);
2938
+ }
2939
+ }
2940
+ }
2941
+ return env.CLAUDE_CODE_OAUTH_TOKEN || undefined;
2942
+ }
2943
+ let credentialFileWarned = false;
2944
+ export function platformModelEnv() {
2121
2945
  const { ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, CLAUDE_CODE_OAUTH_TOKEN,
2122
2946
  // What the tiers mean when the platform's own provider is not Anthropic.
2123
2947
  // These belong to the credential: a base URL pointing at OpenRouter with
@@ -2129,7 +2953,9 @@ function platformModelEnv() {
2129
2953
  ANTHROPIC_API_KEY,
2130
2954
  ANTHROPIC_AUTH_TOKEN,
2131
2955
  ANTHROPIC_BASE_URL,
2132
- CLAUDE_CODE_OAUTH_TOKEN,
2956
+ // The file wins when there is one: it is the live secret, and this
2957
+ // process's copy is whatever it was handed at boot.
2958
+ CLAUDE_CODE_OAUTH_TOKEN: platformModelCredential() ?? CLAUDE_CODE_OAUTH_TOKEN,
2133
2959
  ANTHROPIC_DEFAULT_HAIKU_MODEL,
2134
2960
  ANTHROPIC_DEFAULT_SONNET_MODEL,
2135
2961
  ANTHROPIC_DEFAULT_OPUS_MODEL,
@@ -2177,21 +3003,34 @@ function jsonItems(data, step, take) {
2177
3003
  function csvItems(workspaceDir, step, take) {
2178
3004
  const raw = step.eachPath ?? "";
2179
3005
  if (!raw) {
2180
- step.skipReason = "each: rows needs a path — `each: rows of ../../storage/x.csv`";
3006
+ step.skipReason = "each: rows needs a path — `each: rows of workspace/storage/x.csv`";
2181
3007
  return [];
2182
3008
  }
2183
- const resolved = path.resolve(workspaceDir, "agents", step.agent, raw);
2184
- if (!resolved.startsWith(path.resolve(workspaceDir) + path.sep)) {
2185
- step.skipReason = `each: rows — ${raw} is outside this workspace`;
3009
+ const read = csvDataRows(workspaceDir, step.agent, raw);
3010
+ if ("problem" in read) {
3011
+ step.skipReason = `each: rows — ${read.problem}`;
2186
3012
  return [];
2187
3013
  }
3014
+ return read.data.slice(0, take).map((r) => `${read.header}\n${r}`);
3015
+ }
3016
+ /**
3017
+ * The data rows of a CSV, path agent-relative like every path in a flow and
3018
+ * confined to the workspace. Shared by `each: rows of`, which fans out over
3019
+ * them, and `when: rows of`, which asks only whether there are any.
3020
+ */
3021
+ function csvDataRows(workspaceDir, agent, raw) {
3022
+ // `workspace/storage/x.csv` from the workspace root, anything else from
3023
+ // the agent's folder (`../../storage/x.csv` is the same file).
3024
+ const resolved = resolveAgentPath(workspaceDir, path.join(workspaceDir, "agents", agent), raw);
3025
+ if (!resolved.startsWith(path.resolve(workspaceDir) + path.sep)) {
3026
+ return { problem: `${raw} is outside this workspace` };
3027
+ }
2188
3028
  let text;
2189
3029
  try {
2190
3030
  text = fs.readFileSync(resolved, "utf8");
2191
3031
  }
2192
3032
  catch {
2193
- step.skipReason = `each: rows — ${raw} does not exist (yet?)`;
2194
- return [];
3033
+ return { problem: `${raw} does not exist (yet?)` };
2195
3034
  }
2196
3035
  // Minimal CSV: quoted fields with embedded commas/newlines survive.
2197
3036
  const rows = [];
@@ -2225,23 +3064,21 @@ function csvItems(workspaceDir, step, take) {
2225
3064
  if (row + field)
2226
3065
  rows.push(row + field);
2227
3066
  const [header, ...data] = rows.filter((r) => r.trim() !== "");
2228
- if (!header || data.length === 0) {
2229
- step.skipReason = `each: rows — ${raw} has no data rows`;
2230
- return [];
2231
- }
2232
- return data.slice(0, take).map((r) => `${header}\n${r}`);
3067
+ if (!header || data.length === 0)
3068
+ return { problem: `${raw} has no data rows` };
3069
+ return { header, data };
2233
3070
  }
2234
3071
  /**
2235
3072
  * `schema: <path>` — the file, read and parsed, or null. Agent-relative like
2236
- * every other path in a flow (`../../schemas/lead.json`), confined to the
3073
+ * every other path in a flow (`workspace/schemas/lead.json`), confined to the
2237
3074
  * workspace: a schema is the author's document, never something a step
2238
3075
  * can point outside.
2239
3076
  */
2240
3077
  export function readSchemaFile(workspaceRoot, rel) {
2241
3078
  const root = path.resolve(workspaceRoot);
2242
- // Written agent-relative (`../../schemas/x.json`) or workspace-relative
2243
- // (`schemas/x.json`); both land on the same file.
2244
- const candidates = [path.resolve(root, rel.replace(/^(\.\.\/){2}/, "")), path.resolve(root, "agents", "x", rel)];
3079
+ // Written `workspace/schemas/x.json`, agent-relative (`../../schemas/x.json`)
3080
+ // or workspace-relative (`schemas/x.json`); all land on the same file.
3081
+ const candidates = [path.resolve(root, rel.replace(/^((\.\.\/){2}|workspace\/)/, "")), path.resolve(root, "agents", "x", rel)];
2245
3082
  for (const abs of candidates) {
2246
3083
  if (abs !== root && !abs.startsWith(root + path.sep))
2247
3084
  continue;
@@ -2293,6 +3130,7 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
2293
3130
  schemaPath: s.schemaPath,
2294
3131
  parallel: s.parallel,
2295
3132
  maxTurns: s.maxTurns,
3133
+ limits: s.limits,
2296
3134
  attempts: 0,
2297
3135
  status: "pending",
2298
3136
  events: [],
@@ -2427,6 +3265,19 @@ export function driveRun(tenant, workspace, run, modelOverride, tags = [], opts
2427
3265
  }
2428
3266
  function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2429
3267
  const effortOverride = opts.effortOverride ?? null;
3268
+ // The flow's own calendar. Read from the flow file rather than passed in,
3269
+ // so every door into a run — the queue, an eval, the CLI, a rerun, a
3270
+ // reconcile that picks an abandoned run back up — gets the same clock
3271
+ // without each caller having to remember it. A run that belongs to no
3272
+ // flow (an ad-hoc agent run) finds nothing here and cascades from the
3273
+ // agent to the workspace, which is what it should do.
3274
+ let flowTimezone = null;
3275
+ try {
3276
+ flowTimezone = readFlow(tenant, workspace, run.flow)?.timezone ?? null;
3277
+ }
3278
+ catch {
3279
+ // an unreadable flow file is reported where it is parsed for real
3280
+ }
2430
3281
  const pDir = workspaceDir(tenant, workspace);
2431
3282
  const runRoot = pDir;
2432
3283
  /**
@@ -2860,6 +3711,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
2860
3711
  for (const step of freshGroup) {
2861
3712
  if (step.status !== "pending" || !step.when)
2862
3713
  continue;
3714
+ // `when: rows of <csv>` asks the data, not the prose: run only if
3715
+ // the file has a data row. It is the condition a fan-out's
3716
+ // followers need — an `each:` over an empty file skips itself but
3717
+ // leaves nothing for a marker to be read from, so the steps after
3718
+ // it used to run on nothing.
3719
+ const rowsOf = whenRowsPath(step.when);
3720
+ if (rowsOf !== null) {
3721
+ const read = csvDataRows(pDir, step.agent, rowsOf);
3722
+ if ("problem" in read) {
3723
+ step.status = "skipped";
3724
+ step.skipReason = `condition not met: when rows — ${read.problem}`;
3725
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `skipped — ${read.problem}` });
3726
+ }
3727
+ continue;
3728
+ }
2863
3729
  // A marker at the start of a line, not a word anywhere in the
2864
3730
  // prose — see markerPresent. "There are no BLOCKED items" must
2865
3731
  // not open a gate keyed on BLOCKED.
@@ -3028,13 +3894,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3028
3894
  // An agent's own `budget:` — the most it may spend in one run — is
3029
3895
  // read at launch, so a cap raised mid-run applies to the next step.
3030
3896
  const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
3897
+ // The ceiling each attempt now running was given. A step records its
3898
+ // cost only when it ends, so while it runs its ceiling stands in for
3899
+ // what it may yet spend — otherwise every fan-out copy of one agent
3900
+ // saw none of its siblings' spend and got the agent's whole budget.
3901
+ const reserved = new Map();
3031
3902
  // The ceiling for one attempt of one step, read NOW: what is left of
3032
3903
  // the run's budget shared across the group, or the agent's own cap
3033
- // less what its steps have cost. Computed before every attempt, not
3034
- // once before the loop — a retry's share is what is left after the
3035
- // failed attempt spent, and the step's own figure already carries
3036
- // that spend (it is the sum of its tries).
3037
- const ceilingFor = (step) => stepCeilingFor(stepCeiling(run.budgetUsd, platform.runSpend(run), launching.length), agentBudgets.get(step.agent), run.steps.filter((s) => s !== step && s.agent === step.agent).reduce((sum, s) => sum + (s.costUsd ?? 0), 0), step.agent);
3904
+ // less what its steps have cost or hold in flight, shared with its
3905
+ // steps in this group still waiting to start. Computed before every
3906
+ // attempt, not once before the loop — a retry's share is what is left
3907
+ // after the failed attempt spent, and the step's own figure already
3908
+ // carries that spend (it is the sum of its tries).
3909
+ const ceilingFor = (step) => stepCeilingFor(stepCeiling(run.budgetUsd, platform.runSpend(run), launching.length), agentBudgets.get(step.agent), run.steps
3910
+ .filter((s) => s.agent === step.agent)
3911
+ .reduce((sum, s) => sum + (s.costUsd ?? 0) + (s !== step && s.status === "running" ? (reserved.get(s) ?? 0) : 0), 0), step.agent, 1 + launching.filter((s) => s !== step && s.agent === step.agent && s.status === "pending" && !reserved.has(s)).length);
3038
3912
  // parallel: — the instances of one fan-out template share a pool.
3039
3913
  // Keyed by what the instances share (agent + instruction), since
3040
3914
  // the template itself is behind them in the record as "expanded".
@@ -3094,25 +3968,57 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3094
3968
  const eventsBefore = step.events.length;
3095
3969
  step.attempts = attempt;
3096
3970
  step.status = "running";
3971
+ if (ceiling.ceilingUsd !== null)
3972
+ reserved.set(step, ceiling.ceilingUsd);
3097
3973
  save();
3098
- await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceiling.ceilingUsd, ceiling.note, run.test === true,
3974
+ const verdict = await runStep(path.join(pDir, "agents", step.agent), tenant, step, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, ceiling.ceilingUsd, ceiling.note, run.test === true,
3099
3975
  // save() merges a stop written by another process into
3100
3976
  // this record; the model loop polls this on a clock.
3101
3977
  () => {
3102
3978
  save();
3103
3979
  return run.stopRequested === true;
3104
- });
3980
+ }, flowTimezone, run.steps.indexOf(step)).finally(() => reserved.delete(step));
3105
3981
  // runStep mutates step.status; read it through a widened local
3106
3982
  // so TS doesn't keep the "running" narrowing from above.
3107
3983
  const outcome = step.status;
3108
3984
  recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
3109
3985
  save();
3986
+ // A stop destroys the step's sandbox, which reads here as a
3987
+ // failed attempt. stopRun writes "stopped by a person" onto
3988
+ // this step in the record, and the save() above writes this
3989
+ // process's copy over it — so the trail ended on the
3990
+ // sandbox's own last words, "sandbox ended: Error (exit 143)",
3991
+ // and a person's stop read as the platform killing a healthy
3992
+ // step (abandon-desk, 2026-09-29: stopped from the CLI after
3993
+ // ten minutes, then chased as an unexplained kill). Say so,
3994
+ // whether or not a retry was left.
3995
+ if (outcome === "failed" && run.stopRequested) {
3996
+ step.events.push({
3997
+ t: new Date().toISOString(),
3998
+ type: "info",
3999
+ text: "stopped by a person mid-step — its sandbox was destroyed, which is what ended this attempt; not retried",
4000
+ });
4001
+ save();
4002
+ break;
4003
+ }
3110
4004
  if (outcome !== "failed" || attempt === attempts)
3111
4005
  break;
3112
- // A stop destroys the step's sandbox, which reads here as a
3113
- // failed attempt — and a failed attempt used to be retried in
3114
- // a fresh sandbox, so a stopped `retry: 2` step ran twice
3115
- // more. save() merges the flag from the record.
4006
+ // An outward step whose tools ran and whose check then failed
4007
+ // is not retried: the check cannot say the send did not
4008
+ // happen, and a retry would do it twice.
4009
+ if (verdict?.actedThenFailedCheck) {
4010
+ step.events.push({
4011
+ t: new Date().toISOString(),
4012
+ type: "info",
4013
+ text: `not retried — this step's tools can act outside the workspace and ran before the check failed; another attempt could repeat what they did (${attempts - attempt} left unused)`,
4014
+ });
4015
+ save();
4016
+ break;
4017
+ }
4018
+ // A failed attempt used to be retried in a fresh sandbox even
4019
+ // after a stop, so a stopped `retry: 2` step ran twice more.
4020
+ // The check above ends the loop on a stop; this one covers a
4021
+ // stop that lands while the outward check was being written.
3116
4022
  save();
3117
4023
  if (run.stopRequested)
3118
4024
  break;
@@ -3206,7 +4112,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3206
4112
  rescue.attempts = 1;
3207
4113
  rescue.status = "running";
3208
4114
  save();
3209
- await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true);
4115
+ await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, undefined, flowTimezone, run.steps.indexOf(rescue));
3210
4116
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
3211
4117
  const rescueOutcome = rescue.status;
3212
4118
  if (rescueOutcome === "completed") {
@@ -3368,6 +4274,9 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3368
4274
  // learn, and the runs list and the notification stop having to say
3369
4275
  // only "completed".
3370
4276
  run.summary = runSummary(run);
4277
+ // Read before a test run's prefix goes on the summary, which would
4278
+ // otherwise hide the word it leads with.
4279
+ run.verdict = verdictOf(run.summary);
3371
4280
  // A test run's headline says so wherever the headline goes — the
3372
4281
  // runs list, the history tool, a later run's recall.
3373
4282
  if (run.test && run.summary)
@@ -3645,3 +4554,86 @@ export function runResult(run) {
3645
4554
  }
3646
4555
  return null;
3647
4556
  }
4557
+ /**
4558
+ * What a Docker-run script sees of the workspace and the account: their
4559
+ * scripts/ and their tools/, read-only. A folder tool's code lives in its
4560
+ * tools/<name>/ folder, not in scripts/ — without those two mounts every
4561
+ * folder tool (the hello template's wordcount among them) was handed a host
4562
+ * path the container did not have, and failed on every call. A gallery tool
4563
+ * nobody installed runs from the gallery's own shelf, so that is mounted too
4564
+ * when there is one. A folder that does not exist is skipped by the copy.
4565
+ */
4566
+ export function scriptMounts(agentDir, tenant) {
4567
+ const workspaceRoot = path.resolve(agentDir, "..", "..");
4568
+ const mounts = {
4569
+ [path.join(workspaceRoot, "scripts")]: "/workspace-scripts",
4570
+ [path.join(workspaceRoot, "tools")]: "/workspace-tools",
4571
+ [libraryDir(tenant, "scripts")]: "/library-scripts",
4572
+ [libraryDir(tenant, "tools")]: "/library-tools",
4573
+ };
4574
+ const gallery = platform.galleryDir();
4575
+ if (gallery)
4576
+ mounts[path.join(gallery, "tools")] = "/gallery-tools";
4577
+ return mounts;
4578
+ }
4579
+ /**
4580
+ * The skills an agent can see, nearest first, each with the path the agent
4581
+ * is told to read it at. Own skills, then the workspace's, then the
4582
+ * workspace's `.agents/skills/` (the cross-client convention, scanned after
4583
+ * the native location so a foldrun-native skill wins a name clash), then the
4584
+ * account library. The nearest definition of a name wins.
4585
+ *
4586
+ * The workspace's are written `workspace/skills/…`, the one spelling for the
4587
+ * workspace root, which the file tools expand themselves. `../../skills/…`
4588
+ * left the model to resolve it against a path it had to guess, and on the
4589
+ * hello template it climbed one level too far and was refused.
4590
+ */
4591
+ export function skillsInScope(agentDir, tenant) {
4592
+ const skills = [];
4593
+ const seen = new Set();
4594
+ const add = (dir, prefix, subdir = "skills") => {
4595
+ for (const skill of discoverSkills(dir, subdir)) {
4596
+ if (seen.has(skill.name))
4597
+ continue;
4598
+ seen.add(skill.name);
4599
+ skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
4600
+ }
4601
+ };
4602
+ const workspaceRoot = path.join(agentDir, "..", "..");
4603
+ add(agentDir, "");
4604
+ add(workspaceRoot, "workspace/");
4605
+ add(workspaceRoot, "workspace/", ".agents/skills");
4606
+ // The account library keeps its relative path: `account/` is expanded by
4607
+ // the file tools only, and a skill's bundled scripts are run from the shell.
4608
+ add(path.join(libraryDir(tenant)), "../../../../library/");
4609
+ return skills;
4610
+ }
4611
+ /**
4612
+ * The interpreters a set of scripts needs, read from what they are: the
4613
+ * extension of `run:` (or of inline code), or an explicit `interpreter:`.
4614
+ * Docker's script image is built from the runtime spec, and the base one is
4615
+ * Python only, so a JavaScript tool that declared no `runtime:` (the demo's
4616
+ * inline crm-upload, any folder tool's run.mjs) failed with no node to run
4617
+ * it. Python is named too whenever a .py script is present, because asking
4618
+ * for node alone builds on node:22-slim, which has no python3. Null when
4619
+ * nothing beyond the default is needed.
4620
+ */
4621
+ export function scriptLanguages(scripts) {
4622
+ const kind = (sc) => {
4623
+ const via = (sc.interpreter ?? "").split(/[\\/]/).pop() ?? "";
4624
+ if (/^node/.test(via))
4625
+ return "node";
4626
+ if (/^python/.test(via))
4627
+ return "python";
4628
+ const ext = (sc.run ? path.extname(sc.run) : sc.codeExt ?? "").toLowerCase();
4629
+ if ([".js", ".mjs", ".cjs", ".ts"].includes(ext))
4630
+ return "node";
4631
+ if (ext === ".py")
4632
+ return "python";
4633
+ return null;
4634
+ };
4635
+ const kinds = new Set(scripts.map(kind));
4636
+ if (!kinds.has("node"))
4637
+ return null;
4638
+ return { node: true, ...(kinds.has("python") ? { python: true } : {}), packages: [], npm: [] };
4639
+ }