@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
package/src/runner.ts CHANGED
@@ -9,14 +9,19 @@ import matter from "gray-matter";
9
9
  import { spawn } from "node:child_process";
10
10
  import {
11
11
  knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
12
+ import { cascadeLimits, toolOwners } from "./limits.ts";
13
+ import { resolveAgentPath } from "./confine.ts";
14
+ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.ts";
12
15
  import { eventUrl } from "./webhook.ts";
13
16
  import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
14
17
  import { hostSafeEnv } from "./host-env.ts";
18
+ import { resolveModelCredential, credentialLine } from "./model-credential.ts";
15
19
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
16
20
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
21
+ import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
17
22
  import { platform } from "./platform.ts";
18
23
  import { healthKey } from "./secret-health.ts";
19
- import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.ts";
24
+ import { TEST_WRITES_DIR, overlayTestWrites, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.ts";
20
25
 
21
26
  /** Does this process run steps in a sandbox — the container core ships, or
22
27
  * one the platform registered (a pod)? */
@@ -25,7 +30,10 @@ const isolatedRun = () => {
25
30
  return mode === "container" || (!!mode && mode in platform.isolation);
26
31
  };
27
32
  import { gatherConsults, buildConsultTools } from "./agent-tools.ts";
28
- import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError } from "./tool-names.ts";
33
+ import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools, type SubagentSpec } from "./subagents.ts";
34
+ import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.ts";
35
+ import { sendPlainNotification } from "./notify.ts";
36
+ import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.ts";
29
37
  import { refNames } from "./refs.ts";
30
38
  import {
31
39
  accountDir,
@@ -41,9 +49,11 @@ import {
41
49
  listRuns,
42
50
  syncBundleFor,
43
51
  parseToolDef,
52
+ type ToolDef,
44
53
  parseFlow,
45
54
  readFlow,
46
55
  markerPresent,
56
+ whenRowsPath,
47
57
  parseApis,
48
58
  resolveModel,
49
59
  parseProvider,
@@ -55,6 +65,7 @@ import {
55
65
  readRun,
56
66
  writeRun,
57
67
  runSummary,
68
+ verdictOf,
58
69
  type FlowStep,
59
70
  type McpSpec,
60
71
  type RunRecord,
@@ -75,14 +86,21 @@ import { attachOperations, prefetchOpenApi } from "./openapi.ts";
75
86
  import { buildSearchTools, buildHistoryTools, buildDeskTools, digestRuns, type SearchRoot, type RunDigest } from "./context-tools.ts";
76
87
  import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./translator.ts";
77
88
  import { providerPreset } from "./providers.ts";
78
- import { buildScriptTools, parseScripts, type ExecutionContext } from "./script-tools.ts";
89
+ import { buildScriptTools, parseScripts, type ExecutionContext, type ScriptSpec } from "./script-tools.ts";
79
90
  import { libraryDir, libraryTools, libraryMemoryIndex } from "./library.ts";
80
- import { mergeRuntimes, parseRuntime, prepareRuntime, type RuntimeSpec } from "./runtime.ts";
91
+ import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime, type PreparedRuntime, type RuntimeSpec } from "./runtime.ts";
81
92
  import { materializeFiles, harvestFiles } from "./storage.ts";
82
93
  import { chooseExecutor, ensureImage } from "./container.ts";
83
94
  import { stampBundle } from "./okf.ts";
84
95
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
96
+ import { readBrowseSettings, readSearchSettings, resolveSearch, searchSettingsEnv, webConfig, browseSessionProblems } from "./providers.ts";
97
+ import { NEWER_ACTIONS, resolveActionApi } from "./web-actions.ts";
98
+ import { resolveLanguage, languageName, type LanguageChoice } from "./language.ts";
99
+ import { resolveRegion, deriveLocale, localeProse, regionName, type RegionChoice, type LocaleFacts } from "./locale.ts";
85
100
  import { trimChars } from "./paths.ts";
101
+ import { resolveClock, localDate, type ClockChoice } from "./clock.ts";
102
+
103
+ export { localDate } from "./clock.ts";
86
104
 
87
105
  // An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
88
106
  // in env and headers resolve server-side, so a credential reaches the server
@@ -274,6 +292,29 @@ export function discoverSkills(agentDir: string, subdir = "skills"): DiscoveredS
274
292
  * Returns null rather than throwing on unparseable YAML: a broken shared file
275
293
  * must not take down every agent under it.
276
294
  */
295
+ /**
296
+ * AGENTS.md without the block a coding tool reads.
297
+ *
298
+ * AGENTS.md is two things in a foldrun folder: the account's (or
299
+ * workspace's) own context, which every running agent is given, and — the
300
+ * convention Next.js set — the file a coding agent (Claude Code, Cursor,
301
+ * Codex) reads before it edits the folder. The CLI writes a short managed
302
+ * block for the second reader between these markers; it is about editing
303
+ * the files, not about doing the work, so no running agent is shown it.
304
+ */
305
+ export const AGENT_RULES_START = "<!-- BEGIN:foldrun-agent-rules -->";
306
+ export const AGENT_RULES_END = "<!-- END:foldrun-agent-rules -->";
307
+ export function withoutAgentRules(body: string): string {
308
+ let out = body;
309
+ for (;;) {
310
+ const start = out.indexOf(AGENT_RULES_START);
311
+ if (start === -1) return out;
312
+ const end = out.indexOf(AGENT_RULES_END, start);
313
+ if (end === -1) return out;
314
+ out = out.slice(0, start) + out.slice(end + AGENT_RULES_END.length);
315
+ }
316
+ }
317
+
277
318
  export function readAgentsMd(dir: string): { data: Record<string, unknown>; body: string } | null {
278
319
  // Convert a pre-rename `mdagent_version:` before parsing. One name in the
279
320
  // code, one on disk — the reader no longer knows two spellings, so a file
@@ -284,7 +325,7 @@ export function readAgentsMd(dir: string): { data: Record<string, unknown>; body
284
325
  if (!fs.existsSync(file)) continue;
285
326
  try {
286
327
  const parsed = matter(fs.readFileSync(file, "utf8"));
287
- return { data: parsed.data as Record<string, unknown>, body: parsed.content.trim() };
328
+ return { data: parsed.data as Record<string, unknown>, body: withoutAgentRules(parsed.content).trim() };
288
329
  } catch {
289
330
  return null;
290
331
  }
@@ -303,6 +344,57 @@ const workspaceRootOf = (agentDir: string) => path.join(agentDir, "..", "..");
303
344
  * writing instructions means *as well as*, because an account rule nobody can
304
345
  * silently drop is the point of having one.
305
346
  */
347
+ /** An agent's own `runtime:` block, else its workspace's (AGENTS.md, the
348
+ * Linux Foundation standard name; project.md stays accepted so nothing
349
+ * that already exists breaks). */
350
+ function ownRuntime(front: Record<string, unknown>, agentDir: string, tenant: string): RuntimeSpec | null {
351
+ return parseRuntime(front.runtime) ?? parseRuntime(workspaceFrontmatter(agentDir, tenant).runtime);
352
+ }
353
+
354
+ /** What one granted script tool's program needs. */
355
+ function toolRuntime(def: ToolDef): RuntimeSpec | null {
356
+ return def.kind === "script" ? parseRuntime((def.spec as { runtime?: unknown }).runtime) : null;
357
+ }
358
+
359
+ /**
360
+ * The runtime one agent's steps are built with: its own declaration (else its
361
+ * workspace's), merged with every script tool it grants. The runner builds
362
+ * exactly this for a step; the platform builds it ahead of time when the
363
+ * workspace is deployed, so a package that will not install is reported at
364
+ * deploy and a scheduled step finds its environment ready. One function, so
365
+ * the two can never disagree about what an agent needs.
366
+ */
367
+ export function agentRuntimeSpec(tenant: string, workspace: string, agentDir: string): RuntimeSpec | null {
368
+ const file = path.join(agentDir, "agent.md");
369
+ if (!fs.existsSync(file)) return null;
370
+ const front = safeMatter(fs.readFileSync(file, "utf8")).data;
371
+ const available = { ...libraryTools(tenant), ...workspaceTools(tenant, workspace) };
372
+ const tools = ownToolNames(front)
373
+ .map((name) => available[name])
374
+ .filter((def): def is ToolDef => Boolean(def))
375
+ .map(toolRuntime);
376
+ return mergeRuntimes(ownRuntime(front, agentDir, tenant), ...tools);
377
+ }
378
+
379
+ /** Every distinct runtime a workspace's agents need, with who needs it. */
380
+ export function workspaceRuntimePlan(
381
+ tenant: string,
382
+ workspace: string,
383
+ ): { fingerprint: string; spec: RuntimeSpec; agents: string[] }[] {
384
+ const byFp = new Map<string, { fingerprint: string; spec: RuntimeSpec; agents: string[] }>();
385
+ const agentsRoot = path.join(workspaceDir(tenant, workspace), "agents");
386
+ if (!fs.existsSync(agentsRoot)) return [];
387
+ for (const name of fs.readdirSync(agentsRoot).sort()) {
388
+ const spec = agentRuntimeSpec(tenant, workspace, path.join(agentsRoot, name));
389
+ if (!spec) continue;
390
+ const fp = fingerprint(spec);
391
+ const entry = byFp.get(fp) ?? { fingerprint: fp, spec, agents: [] };
392
+ entry.agents.push(name);
393
+ byFp.set(fp, entry);
394
+ }
395
+ return [...byFp.values()];
396
+ }
397
+
306
398
  function workspaceFrontmatter(agentDir: string, tenant: string): Record<string, unknown> {
307
399
  return {
308
400
  ...(readAgentsMd(accountDir(tenant))?.data ?? {}),
@@ -311,31 +403,69 @@ function workspaceFrontmatter(agentDir: string, tenant: string): Record<string,
311
403
  }
312
404
 
313
405
  /**
314
- * The calendar an agent works to. `timezone:` in AGENTS.md, nearest-wins like
315
- * every other key there; a flow's cron already means its own timezone, this
316
- * makes "today" mean it too. Unset is UTC — what the pods run — which for a
317
- * Sydney desk made an article dated today "one day in the future" until 10am.
318
- * An unknown name falls back to UTC rather than failing every step.
406
+ * The calendar a set of frontmatter asks for, with no cascade around it —
407
+ * one merged record in, one zone out. The full cascade lives in
408
+ * `agentClock` below and in clock.ts; this is what the account-level parts
409
+ * of the platform (a wallet's month, a budget window) call.
319
410
  */
320
411
  export function resolveTimezone(front: Record<string, unknown>): string {
321
- const tz = typeof front.timezone === "string" ? front.timezone.trim() : "";
322
- const chosen = tz || process.env.FOLDRUN_TIMEZONE?.trim() || "UTC";
323
- try {
324
- new Intl.DateTimeFormat("en-CA", { timeZone: chosen });
325
- return chosen;
326
- } catch {
327
- return "UTC";
328
- }
412
+ return resolveClock([{ level: "workspace", value: front.timezone }]).timezone;
329
413
  }
330
414
 
331
- /** YYYY-MM-DD in a timezone — the date the workspace believes it is. */
332
- export function localDate(timeZone: string, now = new Date()): string {
333
- return new Intl.DateTimeFormat("en-CA", {
334
- timeZone,
335
- year: "numeric",
336
- month: "2-digit",
337
- day: "2-digit",
338
- }).format(now);
415
+ /**
416
+ * The clock this step works to: agent, then flow, then workspace, then
417
+ * account, then FOLDRUN_TIMEZONE, then UTC. Nearest wins, and a value that
418
+ * is not a zone falls through to the next level with a line to say so —
419
+ * see clock.ts for why this exists at all.
420
+ */
421
+ export function agentClock(
422
+ agentDir: string,
423
+ tenant: string,
424
+ agentFront: Record<string, unknown>,
425
+ flowTimezone?: string | null,
426
+ ): ClockChoice {
427
+ return resolveClock([
428
+ { level: "agent", value: agentFront.timezone },
429
+ { level: "flow", value: flowTimezone },
430
+ { level: "workspace", value: readAgentsMd(workspaceRootOf(agentDir))?.data?.timezone },
431
+ { level: "account", value: readAgentsMd(accountDir(tenant))?.data?.timezone },
432
+ ]);
433
+ }
434
+
435
+ /**
436
+ * The language this step works in: agent, then workspace, then account, then
437
+ * FOLDRUN_LANGUAGE, then English. The flow level is not read yet — a flow's
438
+ * timezone: travels through three signatures to get here, and this will
439
+ * take the same road when a flow needs it.
440
+ */
441
+ export function agentLanguage(agentDir: string, tenant: string, agentFront: Record<string, unknown>): LanguageChoice {
442
+ return resolveLanguage([
443
+ { level: "agent", value: agentFront.language },
444
+ { level: "workspace", value: readAgentsMd(workspaceRootOf(agentDir))?.data?.language },
445
+ { level: "account", value: readAgentsMd(accountDir(tenant))?.data?.language },
446
+ ]);
447
+ }
448
+
449
+ /**
450
+ * The country this step works for, on the same road as the language, then
451
+ * FOLDRUN_REGION, then whatever the language tag carried (`en-AU` says AU).
452
+ * Everything a country changes — currency, units, calendar, weekend, text
453
+ * direction — is derived from it in locale.ts; the three overrides
454
+ * (`currency:`, `calendar:`, `units:`) are read at the nearest level that
455
+ * wrote them.
456
+ */
457
+ export function agentRegion(agentDir: string, tenant: string, agentFront: Record<string, unknown>, languageTag: string): RegionChoice {
458
+ return resolveRegion([
459
+ { level: "agent", value: agentFront.region },
460
+ { level: "workspace", value: readAgentsMd(workspaceRootOf(agentDir))?.data?.region },
461
+ { level: "account", value: readAgentsMd(accountDir(tenant))?.data?.region },
462
+ ], languageTag);
463
+ }
464
+
465
+ export function agentLocaleOverrides(agentDir: string, tenant: string, agentFront: Record<string, unknown>): { currency?: unknown; calendar?: unknown; units?: unknown } {
466
+ const levels = [agentFront, readAgentsMd(workspaceRootOf(agentDir))?.data ?? {}, readAgentsMd(accountDir(tenant))?.data ?? {}];
467
+ const pick = (k: string) => levels.map((l) => l[k]).find((v) => v !== undefined && v !== null && v !== "");
468
+ return { currency: pick("currency"), calendar: pick("calendar"), units: pick("units") };
339
469
  }
340
470
 
341
471
  /**
@@ -345,6 +475,65 @@ export function localDate(timeZone: string, now = new Date()): string {
345
475
  * template as "context every agent here shares" — and never read. Only the
346
476
  * frontmatter was, so the instructions people actually wrote reached no model.
347
477
  */
478
+ /**
479
+ * The sub-agents a step may delegate to (`subagents:`), as the SDK will get
480
+ * them: each resolved through agentContext exactly as its own step would be
481
+ * (grants only — no runtime; it runs inside its parent's), then cut to what
482
+ * the parent holds. Problems are lines on the step, never a failed step.
483
+ */
484
+ export function buildSubagentSpecs(o: {
485
+ names: unknown;
486
+ self: string;
487
+ workspaceRoot: string;
488
+ tenant: string;
489
+ tags?: string[];
490
+ runId?: string;
491
+ flowTimezone?: string | null;
492
+ parentAllowed: string[];
493
+ push: (type: "info" | "error", text: string) => void;
494
+ }): SubagentSpec[] {
495
+ const out: SubagentSpec[] = [];
496
+ const { found, missing } = gatherSubagents(o.workspaceRoot, o.names);
497
+ for (const m of missing) o.push("error", `subagents: "${m}" is not an agent in this workspace — not delegated to`);
498
+ for (const sub of found) {
499
+ if (sub.name === o.self) {
500
+ o.push("error", `subagents: "${sub.name}" is this agent itself — an agent does not delegate to itself`);
501
+ continue;
502
+ }
503
+ try {
504
+ const tags = o.tags ?? [];
505
+ const own = agentContext(sub.dir, o.tenant, tags, { runId: o.runId, agent: sub.name }, o.flowTimezone, true);
506
+ const description = typeof own.front.description === "string" ? own.front.description.trim() : "";
507
+ if (!description) {
508
+ o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
509
+ continue;
510
+ }
511
+ const tools = subagentTools(own.allowed, o.parentAllowed);
512
+ if (!tools.length) {
513
+ 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)`);
514
+ }
515
+ const body = withoutAgentRules(matter(fs.readFileSync(path.join(sub.dir, "agent.md"), "utf8")).content);
516
+ out.push({
517
+ name: sub.name,
518
+ description,
519
+ prompt: subagentPrompt({
520
+ body: resolveDocLinks(body, o.workspaceRoot),
521
+ name: sub.name,
522
+ parent: o.self,
523
+ workspace: path.basename(o.workspaceRoot),
524
+ shared: sharedInstructions(sub.dir, o.tenant),
525
+ }),
526
+ tools,
527
+ disallowedTools: refNames(own.front.disallowedTools),
528
+ model: own.front.model ? resolveModel(own.front.model) : "inherit",
529
+ });
530
+ } catch (err) {
531
+ o.push("error", `subagents: "${sub.name}" could not be read — ${err instanceof Error ? err.message : String(err)}`);
532
+ }
533
+ }
534
+ return out;
535
+ }
536
+
348
537
  export function sharedInstructions(agentDir: string, tenant: string): string | null {
349
538
  const sections = [
350
539
  ["Everyone in this account", readAgentsMd(accountDir(tenant))?.body],
@@ -433,7 +622,9 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
433
622
  // instruction names needs to say WHERE without naming a file. Added
434
623
  // first, so a document that happens to share a folder's name still wins.
435
624
  for (const dir of [STORAGE_DIR, "state", "knowledge", "memory", "skills", "outputs"]) {
436
- if (fs.existsSync(path.join(workspaceRoot, dir))) map.set(norm(dir), `../../${dir}/`);
625
+ // storage/ and state/ by the one spelling that works in every tool,
626
+ // the shell included; the rest as they always were.
627
+ if (fs.existsSync(path.join(workspaceRoot, dir))) map.set(norm(dir), dir === STORAGE_DIR || dir === "state" ? `workspace/${dir}/` : `../../${dir}/`);
437
628
  }
438
629
  for (const kind of ["knowledge", "memory"] as const) {
439
630
  const dir = path.join(workspaceRoot, kind);
@@ -472,7 +663,7 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
472
663
  continue;
473
664
  }
474
665
  const fwd = entry.split(path.sep).join("/");
475
- const rel = `../../state/${fwd}`;
666
+ const rel = `workspace/state/${fwd}`;
476
667
  map.set(norm(`state/${fwd}`), rel);
477
668
  map.set(norm(fwd), rel);
478
669
  }
@@ -488,7 +679,7 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
488
679
  "utf8",
489
680
  ),
490
681
  ) as { files?: { path: string }[] };
491
- for (const f of index.files ?? []) map.set(norm(`${STORAGE_DIR}/${f.path}`), `../../${STORAGE_DIR}/${f.path}`);
682
+ for (const f of index.files ?? []) map.set(norm(`${STORAGE_DIR}/${f.path}`), `workspace/${STORAGE_DIR}/${f.path}`);
492
683
  } catch {
493
684
  // no file store index is normal
494
685
  }
@@ -503,8 +694,8 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
503
694
  }
504
695
  const fwd = entry.split(path.sep).join("/");
505
696
  if (fwd.startsWith(".store/")) continue;
506
- map.set(norm(`${STORAGE_DIR}/${fwd}`), `../../${STORAGE_DIR}/${fwd}`);
507
- map.set(norm(fwd), `../../${STORAGE_DIR}/${fwd}`);
697
+ map.set(norm(`${STORAGE_DIR}/${fwd}`), `workspace/${STORAGE_DIR}/${fwd}`);
698
+ map.set(norm(fwd), `workspace/${STORAGE_DIR}/${fwd}`);
508
699
  }
509
700
  }
510
701
  // `[` excluded from the name: with it allowed, a run of `[[` makes the
@@ -522,6 +713,12 @@ function agentContext(
522
713
  tags: string[] = [],
523
714
  /** Who is running, so scripts can stamp what they write with its provenance. */
524
715
  identity: { runId?: string; agent?: string } = {},
716
+ /** The flow's own `timezone:`, when this step belongs to one. Between the
717
+ * agent's frontmatter and the workspace's AGENTS.md in the cascade. */
718
+ flowTimezone?: string | null,
719
+ /** Only what the agent is granted — no runtime prepared. For a sub-agent,
720
+ * whose tools are cut to its parent's and run in the parent's runtime. */
721
+ grantsOnly = false,
525
722
  ) {
526
723
  // agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
527
724
  // workspace a secret should resolve against is two levels up.
@@ -536,13 +733,36 @@ function agentContext(
536
733
  // than implied: told to read `../../knowledge/index.md`, the model asked for
537
734
  // `/knowledge/index.md` twice before working it out. Two denials and a turn,
538
735
  // for want of one sentence.
736
+ // The language this step works in, resolved before the prompt is built:
737
+ // its sentence goes in here, the env the tools read is set below, and the
738
+ // step prints any level that wrote something unreadable.
739
+ const language = agentLanguage(agentDir, tenant, front);
740
+ const region = agentRegion(agentDir, tenant, front, language.language);
741
+ // The clock is resolved further down for the env; the locale needs its
742
+ // zone now so "today" in a Jalali or Hijri calendar is today where the
743
+ // step works, not in UTC.
744
+ const localeZone = agentClock(agentDir, tenant, front, flowTimezone).timezone;
745
+ const locale: LocaleFacts = deriveLocale(language.language, region.region, agentLocaleOverrides(agentDir, tenant, front), localeZone);
746
+ // Said only when someone set it: an agent that never mentions a language
747
+ // or a region reads exactly the prompt it always did.
748
+ const languageSet = language.from !== "default" && language.from !== "env";
749
+ const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
750
+ if (languageSet || regionSet) {
751
+ parts.push(
752
+ `# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
753
+ `Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
754
+ `use these currency, units and calendar conventions in anything you produce. ` +
755
+ `Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`,
756
+ );
757
+ }
539
758
  parts.push(
540
759
  `# Where you are\n\n` +
541
760
  `Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
542
761
  `\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
543
- `\`outputs/\` is yours, and \`../../\` is the workspace root, so the workspace's ` +
544
- `own knowledge is at \`../../knowledge/\`. Absolute paths are outside the ` +
545
- `workspace and will be refused.\n\n` +
762
+ `\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
763
+ `and in the shell: the shared file store is \`workspace/storage/\`, what runs ` +
764
+ `carry forward is \`workspace/state/\`, its knowledge \`workspace/knowledge/\`. ` +
765
+ `Absolute paths are outside the workspace and will be refused.\n\n` +
546
766
  // A flow's whole point is that a later step works on what an earlier one
547
767
  // produced, and each agent writes to its own outputs/. A run showed what
548
768
  // omitting this costs: the checker looked in its own empty outputs/ and
@@ -563,10 +783,18 @@ function agentContext(
563
783
  // adoptLegacyFilesDir's "move files/ to storage/" is a no-op by the time
564
784
  // the run ends — a deliverable written where this text pointed was
565
785
  // harvested from nowhere and silently never appeared.
566
- `\`../../storage/\` is the workspace file store: anything you leave there is kept ` +
786
+ `\`workspace/storage/\` is the workspace file store: anything you leave there is kept ` +
567
787
  `after the run and shown on the Storage page for people to download. Write ` +
568
- `deliverables people asked for — CSVs, reports, PDFs, images — to \`../../storage/\`; ` +
569
- `use \`outputs/\` for working text the next step reads.`,
788
+ `deliverables people asked for — CSVs, reports, PDFs, images — to \`workspace/storage/\`; ` +
789
+ `use \`outputs/\` for working text the next step reads. ` +
790
+ // A Glob PATTERN that climbs (`../../storage/draft/*.md`) always answered
791
+ // "No files found", even when the file was there. On blog-desk that made
792
+ // every loop pass look like a first pass: the illustrator never saw the
793
+ // fact-check report, re-picked the cover, and 2 of 3 publish runs failed
794
+ // (run-munef2am-439a, 2026-09-30). checkPaths now splits such a pattern
795
+ // into Glob's `path` and the rest; the advice stays, as the plain form.
796
+ `To look for files there, open a known file with Read by its exact path, or ` +
797
+ `Glob with \`path: "workspace/storage/..."\` and a pattern like \`*.md\`.`,
570
798
  );
571
799
 
572
800
  // Shared context before anything derived — an account or workspace rule is
@@ -584,24 +812,7 @@ function agentContext(
584
812
  // skills for a few tokens each.
585
813
  // Own skills, then the workspace's, then the workspace library — nearest
586
814
  // definition of a name wins, so a team default can be overridden locally.
587
- const skills: DiscoveredSkill[] = [];
588
- const seenSkills = new Set<string>();
589
- const addSkills = (dir: string, prefix: string, subdir = "skills") => {
590
- for (const skill of discoverSkills(dir, subdir)) {
591
- if (seenSkills.has(skill.name)) continue;
592
- seenSkills.add(skill.name);
593
- skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
594
- }
595
- };
596
- const workspaceRoot = path.join(agentDir, "..", "..");
597
- addSkills(agentDir, "");
598
- addSkills(workspaceRoot, "../../"); // the workspace's skills/
599
- // The cross-client convention: skills any skills-compatible tool installs
600
- // under .agents/skills/ are visible here too, and vice versa. Scanned after
601
- // the native location so a foldrun-native skill wins a name clash, but both
602
- // are discovered. project scope only — foldrun workspaces are self-contained.
603
- addSkills(workspaceRoot, "../../", ".agents/skills");
604
- addSkills(path.join(libraryDir(tenant)), "../../../../library/"); // the account library
815
+ const skills = skillsInScope(agentDir, tenant);
605
816
 
606
817
  // A skill with `when:` only loads when the run carries a matching tag —
607
818
  // so an agent with 119 skills costs 119 lines of context only when every
@@ -691,7 +902,7 @@ function agentContext(
691
902
  };
692
903
 
693
904
  const own = listDir(path.join(agentDir, "scripts"), "scripts/");
694
- const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "../../scripts/");
905
+ const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "workspace/scripts/");
695
906
  // The library's path depends on where this run executes: isolated runs see
696
907
  // it at /library, host runs at its real location. Printing the host path
697
908
  // into a container's prompt handed the model files it could never open.
@@ -720,7 +931,7 @@ function agentContext(
720
931
  // for it. The difference is that state is small enough that inlining would
721
932
  // usually be free — that is a threshold worth adding once there is a real
722
933
  // file to size it against, not before.
723
- const state = listDir(path.join(agentDir, "..", "..", "state"), "../../state/");
934
+ const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
724
935
  if (state.length) {
725
936
  parts.push(
726
937
  `# State — what you carry between runs\n\n` +
@@ -758,16 +969,29 @@ function agentContext(
758
969
  // below. The tool is the unit of code, so its dependencies live in tool.md
759
970
  // and travel with it; the agent should not have to repeat them.
760
971
  const toolRuntimes: RuntimeSpec[] = [];
972
+ // The secrets a granted script tool's file lists (`secrets: [RESEND_API_KEY,
973
+ // EMAIL_FROM]`): granting the tool grants them, the way an API tool's
974
+ // referenced credentials always travelled with it. Before, only the
975
+ // agent's own `secrets:` counted, so an agent that granted desk_email and
976
+ // declared nothing failed with "RESEND_API_KEY is not set" (strata-desk,
977
+ // 1 Oct). `proxied` / `materialised` say how, not what, and add nothing.
978
+ const toolSecrets: string[] = [];
979
+ // Does any granted tool act outside the workspace (`outward: true`)? The
980
+ // retry policy reads it: see launchStep.
981
+ let outward = false;
761
982
  const grantOwnTool = (name: string) => {
762
983
  const def = available[name];
763
984
  if (!def) {
764
985
  missingTools.push(name);
765
986
  return;
766
987
  }
988
+ if (def.outward) outward = true;
767
989
  if (def.kind === "http") apis.push(def.spec);
768
990
  else if (def.kind === "script") {
769
991
  scriptSpecs.push(...parseScripts([def.spec]));
770
- const rt = parseRuntime((def.spec as { runtime?: unknown }).runtime);
992
+ const listed = (def.spec as { secrets?: unknown }).secrets;
993
+ if (Array.isArray(listed)) toolSecrets.push(...listed.map(String).filter((n) => /^[A-Z][A-Z0-9_]*$/.test(n)));
994
+ const rt = toolRuntime(def);
771
995
  if (rt) toolRuntimes.push(rt);
772
996
  }
773
997
  else {
@@ -812,7 +1036,65 @@ function agentContext(
812
1036
  //
813
1037
  // Declared-and-absent is still an error; referenced-and-absent is the API
814
1038
  // tool's own missingSecrets, reported where the tool is described.
815
- const declared: string[] = Array.isArray(front.secrets) ? front.secrets.map(String) : [];
1039
+ // `web: {search: …, fetch: …}` name who answers; resolved here, early,
1040
+ // because three later things hang off the answer — the secret the step
1041
+ // must hold, the env the tool reads, and the egress grant for the host.
1042
+ // `web.search` carries the account engine's settings (engines, categories,
1043
+ // safesearch, …) beside the provider name, read off first the way
1044
+ // browse's are, so the provider resolver sees only its own part.
1045
+ const web = webConfig(front as Record<string, unknown>, workspaceFrontmatter(agentDir, tenant));
1046
+ const searchRead = readSearchSettings(web.raw.search);
1047
+ const searchChoice = resolveSearch(searchRead.rest, "search");
1048
+ if (searchRead.error) searchChoice.error = searchChoice.error ?? searchRead.error;
1049
+ const fetchChoice = resolveSearch(web.raw.fetch, "fetch");
1050
+ // A remote browser's key is materialised, not proxied: CDP is a websocket
1051
+ // and the wrapper opens the session itself, the way it seeds a cookie.
1052
+ // `web.browse` carries two things in one key: how the browser presents
1053
+ // itself (engine, user agent, device, locale, timezone) and, optionally,
1054
+ // which remote vendor renders the page. Settings are read off first so the
1055
+ // vendor resolver sees only the vendor part, and a block of settings alone
1056
+ // means the account's own browser with those defaults. Nearest wins: the
1057
+ // agent's block, else the workspace's, else the account's — the shape
1058
+ // `provider:` already uses.
1059
+ const browseRead = readBrowseSettings(web.raw.browse);
1060
+ const browseSettings = browseRead.settings;
1061
+ const browseChoice = resolveSearch(browseRead.rest, "browse");
1062
+ if (browseRead.error) browseChoice.error = browseChoice.error ?? browseRead.error;
1063
+ // What the vendor's own session is asked for, checked against what that
1064
+ // vendor offers; a problem is on the record and the session is not sent.
1065
+ const sessionProblems = browseChoice.error ? [] : browseSessionProblems(browseSettings, browseChoice.provider);
1066
+ const browseSession = browseChoice.shape === "direct" && browseChoice.provider && !sessionProblems.length ? browseSettings.vendor_session : undefined;
1067
+ // crawl, map, extract, answer, monitor: a provider each, or foldrun's own.
1068
+ // Same three consequences as a search API — the secret the step holds, the
1069
+ // env the web tool reads, the one host the key may reach.
1070
+ const actionChoices = NEWER_ACTIONS.map((action) => ({ action, ...resolveActionApi(action, web.raw[action]) }));
1071
+ const builtins = [
1072
+ ...(searchChoice.provider && searchChoice.shape !== "direct" && !searchChoice.error ? ["search=WebSearch"] : []),
1073
+ ...(fetchChoice.provider && fetchChoice.shape !== "direct" && !fetchChoice.error ? ["fetch=WebFetch"] : []),
1074
+ ];
1075
+ const webBuiltin: Record<string, string> = builtins.length ? { FOLDRUN_WEB_BUILTIN: builtins.join(",") } : {};
1076
+ // A direct search API is paid for with the customer's own key, stored
1077
+ // under a fixed vault name (EXA_API_KEY, BRAVE_SEARCH_API_KEY, …). Naming
1078
+ // the API in frontmatter is declaring that secret — nobody should have to
1079
+ // write it twice, and a run that forgot the second line would search
1080
+ // nothing and say so only at the provider.
1081
+ const declared: string[] = [
1082
+ ...(Array.isArray(front.secrets) ? front.secrets.map(String) : []),
1083
+ ...toolSecrets,
1084
+ ...actionChoices.flatMap((c) => (c.provider ? [c.secret] : [])),
1085
+ // Your own proxy for a vendor's session: its URL is a secret, declared by
1086
+ // naming it, like a provider's key.
1087
+ ...(browseSession && typeof browseSession.proxy === "object" && browseSession.proxy.own ? [browseSession.proxy.own] : []),
1088
+ ...[searchChoice, fetchChoice, browseChoice].flatMap((c) => {
1089
+ if (c.shape !== "direct" || !c.secret) return [];
1090
+ // Jina's reader answers without a key. Declaring an absent optional
1091
+ // key would fail the step for a credential it never needed.
1092
+ if (c.secretOptional && !getSecret(tenant, c.secret, workspace)) return [];
1093
+ return [c.secret];
1094
+ }),
1095
+ ];
1096
+ // One name once, however many places asked for it.
1097
+ declared.splice(0, declared.length, ...new Set(declared));
816
1098
  const referenced = [...new Set(apis.flatMap(secretsUsedByApi))];
817
1099
  const {
818
1100
  env: secretEnv,
@@ -927,57 +1209,131 @@ function agentContext(
927
1209
  // Runtime: an agent's own `runtime:` block, else its workspace's. Built once
928
1210
  // per declaration and cached, so scripts get their dependencies without
929
1211
  // polluting the host.
930
- let runtimeSpec = parseRuntime(front.runtime);
931
- if (!runtimeSpec) {
932
- // AGENTS.md is the Linux Foundation standard name (60k+ repos, same
933
- // nearest-wins cascade we already use); project.md stays accepted so
934
- // nothing that already exists breaks.
935
- runtimeSpec = parseRuntime(workspaceFrontmatter(agentDir, tenant).runtime);
936
- }
937
1212
  // Plus whatever the tools this agent granted declared for themselves.
938
- runtimeSpec = mergeRuntimes(runtimeSpec, ...toolRuntimes);
1213
+ let runtimeSpec = mergeRuntimes(ownRuntime(front, agentDir, tenant), ...toolRuntimes);
939
1214
  // Execution: a container per run when Docker is available (real isolation),
940
1215
  // otherwise the host venv path so local development still works.
941
1216
  const executor = chooseExecutor();
942
1217
  const runtimeLog: string[] = [];
943
1218
  let runtimeError: string | null = null;
944
1219
  let exec: ExecutionContext | null = null;
945
- let runtime = { interpreters: {} as Record<string, string>, env: {} as Record<string, string>, log: [] as string[], error: null as string | null };
946
-
947
- if (executor === "docker") {
948
- const image = ensureImage(runtimeSpec);
1220
+ let runtime: PreparedRuntime = { interpreters: {}, env: {}, log: [], error: null };
1221
+
1222
+ if (executor === "docker" && !grantsOnly) {
1223
+ // The script image also carries what the scripts' own languages need: a
1224
+ // .mjs tool with no runtime: block still needs node in its container.
1225
+ // Here only — the run container and the host have node already, and a
1226
+ // spec there sets off the runtime build for nothing.
1227
+ const image = ensureImage(mergeRuntimes(runtimeSpec, scriptLanguages(scriptSpecs)));
949
1228
  runtimeLog.push(...image.log);
950
1229
  runtimeError = image.error;
951
1230
  if (!image.error) {
952
1231
  exec = {
953
1232
  executor: "docker",
954
1233
  image: image.tag,
955
- mounts: {
956
- [path.resolve(agentDir, "..", "..", "scripts")]: "/workspace-scripts",
957
- [libraryDir(tenant, "scripts")]: "/library-scripts",
958
- },
1234
+ mounts: scriptMounts(agentDir, tenant),
959
1235
  // Only agents that granted something reaching out — an `apis:` entry
960
1236
  // or one of their own tools — get egress.
961
1237
  network: parseApis(front.apis).length > 0 || ownToolNames(front).length > 0,
962
1238
  };
963
1239
  }
1240
+ } else if (isolatedRun() || grantsOnly) {
1241
+ // (A sub-agent's grants are all that is asked of it: it runs inside its
1242
+ // parent's step and runtime.)
1243
+ // The sandbox builds it (run-container.ts, beside pip's network and the
1244
+ // mounted cache); building it here as well was pure waste, and on a
1245
+ // worker image without python it was worse than waste — it logged
1246
+ // "python3 is not available on this host" as an error on every Python
1247
+ // step, above a sandbox build that had succeeded, and sent every reader
1248
+ // of the trace after the wrong machine.
964
1249
  } else {
965
1250
  runtime = prepareRuntime(tenant, runtimeSpec);
966
1251
  runtimeLog.push(...runtime.log);
967
1252
  runtimeError = runtime.error;
968
1253
  }
969
1254
 
970
- // What day it is, in the workspace's own timezone. Not a secret, so it
971
- // travels beside secretEnv rather than inside it — a value in there is
972
- // redacted from every log line that quotes it.
973
- const timezone = resolveTimezone(workspaceFrontmatter(agentDir, tenant));
1255
+ // What day it is, in the calendar this step works to — the agent's own
1256
+ // `timezone:`, else its flow's, else the workspace's, else the account's.
1257
+ // Not a secret, so it travels beside secretEnv rather than inside it — a
1258
+ // value in there is redacted from every log line that quotes it.
1259
+ const clock = agentClock(agentDir, tenant, front, flowTimezone);
1260
+ const timezone = clock.timezone;
974
1261
  const clockEnv = {
975
- TZ: timezone,
1262
+ // The language the agent works in — what the search asks in, the locale
1263
+ // the browser reports, the Accept-Language a fetch sends. Not a secret.
1264
+ FOLDRUN_LANGUAGE: language.language,
1265
+ // The country and what follows from it — web search's gl, a script's
1266
+ // currency. Unset where nobody said, so nothing is invented.
1267
+ ...(region.region ? { FOLDRUN_REGION: region.region } : {}),
1268
+ ...(locale.currency ? { FOLDRUN_CURRENCY: locale.currency } : {}),
1269
+ FOLDRUN_UNITS: locale.units,
1270
+ FOLDRUN_CALENDAR: locale.calendar,
1271
+ ...(locale.dateLocal ? { FOLDRUN_DATE_LOCAL: locale.dateLocal } : {}),
1272
+ FOLDRUN_RTL: locale.direction === "rtl" ? "1" : "0",
1273
+ // TZ is the form the sandbox's shell and Node both read; for a fixed
1274
+ // offset that is not the same string Intl was given (see clock.ts).
1275
+ TZ: clock.tz,
976
1276
  FOLDRUN_DATE: localDate(timezone),
977
1277
  // Where the account's own search engine answers, when the install runs
978
1278
  // one — the `websearch` library tool reads it. Not a secret; rides with
979
1279
  // the clock so it reaches scripts, the sandbox and verify alike.
980
1280
  ...(process.env.FOLDRUN_SEARCH_URL ? { FOLDRUN_SEARCH_URL: process.env.FOLDRUN_SEARCH_URL } : {}),
1281
+ // Whose index our web search asks when the agent named a search API.
1282
+ // Unset means the account's own engine. The name, never the key.
1283
+ ...(searchChoice.shape === "direct" && searchChoice.provider
1284
+ ? { FOLDRUN_WEB_SEARCH_VIA: searchChoice.provider, FOLDRUN_WEB_SEARCH_SECRET: searchChoice.secret ?? "" }
1285
+ : {}),
1286
+ // The `web.search` block's SearXNG settings, as defaults the tool reads.
1287
+ ...searchSettingsEnv(searchRead.settings),
1288
+ ...(fetchChoice.shape === "direct" && fetchChoice.provider
1289
+ ? { FOLDRUN_WEB_FETCH_VIA: fetchChoice.provider, FOLDRUN_WEB_FETCH_SECRET: fetchChoice.secret ?? "" }
1290
+ : {}),
1291
+ ...Object.fromEntries(actionChoices.flatMap((c) => (c.provider
1292
+ ? [[`FOLDRUN_WEB_${c.action.toUpperCase()}_VIA`, c.provider], [`FOLDRUN_WEB_${c.action.toUpperCase()}_SECRET`, c.secret]]
1293
+ : []))),
1294
+ // Which actions the web tool may take (`web: {actions: [...]}`); unset is all.
1295
+ ...(web.actions ? { FOLDRUN_WEB_ACTIONS: web.actions.join(",") } : {}),
1296
+ // A model provider's own search or fetch, which the web tool hands over to.
1297
+ ...webBuiltin,
1298
+ // A remote browser: the wrapper connects over CDP to this vendor with
1299
+ // the named key, and the platform leaves the account's pod alone.
1300
+ ...(browseChoice.shape === "direct" && browseChoice.provider
1301
+ ? { FOLDRUN_BROWSER_VENDOR: browseChoice.provider, FOLDRUN_BROWSER_SECRET: browseChoice.secret ?? "" }
1302
+ : {}),
1303
+ // The vendor session's settings (`web.browse.vendor_session:`), as JSON
1304
+ // the tool maps onto that vendor's own option names. Under both names
1305
+ // for one release: a web tool from before the rename reads the old one.
1306
+ ...(browseSession ? { FOLDRUN_BROWSER_VENDOR_SESSION: JSON.stringify(browseSession), FOLDRUN_BROWSER_SESSION: JSON.stringify(browseSession) } : {}),
1307
+ // The `web.browse` block, as defaults the tool reads when the call did
1308
+ // not say otherwise. Env rather than arguments because the tool is a
1309
+ // script the model calls: a default nobody typed should not have to be
1310
+ // typed by the model either.
1311
+ ...(browseSettings.engine ? { FOLDRUN_BROWSER_ENGINE: browseSettings.engine } : {}),
1312
+ ...(browseSettings.user_agent ? { FOLDRUN_BROWSER_USER_AGENT: browseSettings.user_agent } : {}),
1313
+ ...(browseSettings.device ? { FOLDRUN_BROWSER_DEVICE: browseSettings.device } : {}),
1314
+ ...(browseSettings.locale ? { FOLDRUN_BROWSER_LOCALE: browseSettings.locale } : {}),
1315
+ ...(browseSettings.timezone ? { FOLDRUN_BROWSER_TIMEZONE: browseSettings.timezone } : {}),
1316
+ ...(browseSettings.cookies ? { FOLDRUN_BROWSER_COOKIES: browseSettings.cookies } : {}),
1317
+ ...(browseSettings.cookie_domain ? { FOLDRUN_BROWSER_COOKIE_DOMAIN: browseSettings.cookie_domain } : {}),
1318
+ ...(browseSettings.storage ? { FOLDRUN_BROWSER_STORAGE: browseSettings.storage } : {}),
1319
+ ...(browseSettings.storage_origin ? { FOLDRUN_BROWSER_STORAGE_ORIGIN: browseSettings.storage_origin } : {}),
1320
+ // The named identities, as one JSON map the tool picks from by name.
1321
+ ...(browseSettings.identities ? { FOLDRUN_BROWSER_IDENTITIES: JSON.stringify(browseSettings.identities) } : {}),
1322
+ // Only the non-default travels: a window, on a virtual screen if need be.
1323
+ ...(browseSettings.headless === false ? { FOLDRUN_BROWSER_HEADLESS: "0" } : {}),
1324
+ ...(browseSettings.version ? { FOLDRUN_BROWSER_VERSION: browseSettings.version } : {}),
1325
+ ...(browseSettings.live ? { FOLDRUN_BROWSER_LIVE: "1" } : {}),
1326
+ // What the agent may do, from its file: a lock the call cannot widen.
1327
+ ...(browseSettings.allowed_domains ? { FOLDRUN_BROWSER_ALLOWED_DOMAINS: browseSettings.allowed_domains.join(",") } : {}),
1328
+ ...(browseSettings.deny ? { FOLDRUN_BROWSER_DENY: browseSettings.deny.join(",") } : {}),
1329
+ ...(browseSettings.boundaries ? { FOLDRUN_BROWSER_BOUNDARIES: "1" } : {}),
1330
+ ...(browseSettings.init ? { FOLDRUN_BROWSER_INIT: browseSettings.init.join(",") } : {}),
1331
+ ...(browseSettings.extensions ? { FOLDRUN_BROWSER_EXTENSIONS: browseSettings.extensions.join(",") } : {}),
1332
+ ...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
1333
+ ...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
1334
+ ...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
1335
+ ...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
1336
+ ...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
981
1337
  };
982
1338
 
983
1339
  // Scripts declared as tools — callable by name, no bash required.
@@ -1022,7 +1378,7 @@ function agentContext(
1022
1378
  parts.push(
1023
1379
  "Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
1024
1380
  "what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
1025
- "for the workspace root (../../storage/ and workspace/storage/ are the same place). There is no /tmp for you: a " +
1381
+ "for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
1026
1382
  "directory a tool made outside the workspace, such as a checkout, is reached only through that tool.",
1027
1383
  );
1028
1384
 
@@ -1049,9 +1405,37 @@ function agentContext(
1049
1405
  const disabled: string[] = [];
1050
1406
  const unknownTools: string[] = [];
1051
1407
  const shadowed: string[] = [];
1408
+ // `web: {search: …, fetch: …}` name who answers. Unset means ours — the
1409
+ // account's own engine, and our own fetch, both inside the run's sandbox
1410
+ // and both on the run record. A model provider's name adds the SDK's
1411
+ // server-side tool beside ours, which the run's endpoint executes: point
1412
+ // the run at z.ai and z.ai's index answers the search. The web tool hands
1413
+ // the action over to it (FOLDRUN_WEB_BUILTIN).
1414
+ const providerWebTools: string[] = [];
1415
+ if (browseChoice.error) providerWarnings.push(browseChoice.error);
1416
+ providerWarnings.push(
1417
+ ...sessionProblems,
1418
+ ...web.problems,
1419
+ ...actionChoices.flatMap((c) => (c.error ? [c.error] : [])),
1420
+ );
1421
+ for (const [choice, builtin] of [
1422
+ [searchChoice, "WebSearch"],
1423
+ [fetchChoice, "WebFetch"],
1424
+ ] as const) {
1425
+ if (choice.error) {
1426
+ providerWarnings.push(choice.error);
1427
+ } else if (choice.provider && choice.shape !== "direct") {
1428
+ // A provider's server-side tool replaces ours. A direct search API
1429
+ // does not: our tool stays, and reads FOLDRUN_WEB_SEARCH_VIA to know
1430
+ // whose index to ask — through the egress proxy, with the customer's
1431
+ // own key, on the run record.
1432
+ providerWebTools.push(builtin);
1433
+ }
1434
+ }
1052
1435
  let wantSearch = false;
1053
1436
  let wantHistory = false;
1054
1437
  let wantDesks = false;
1438
+ let wantAsk = false;
1055
1439
 
1056
1440
  for (const ref of toolRefs(front)) {
1057
1441
  const toolName = ref.name;
@@ -1059,6 +1443,14 @@ function agentContext(
1059
1443
  disabled.push(toolName);
1060
1444
  continue;
1061
1445
  }
1446
+ // `web` is our own tool, granted with the rest of them above. A model
1447
+ // provider answering search or fetch on its own side (`web: {search:
1448
+ // zai}`) adds that provider's tool beside it; the web tool's search then
1449
+ // names it (FOLDRUN_WEB_BUILTIN) rather than asking our engine.
1450
+ if (toolName === "web" && !ref.linked) {
1451
+ allowed.push(...providerWebTools);
1452
+ continue;
1453
+ }
1062
1454
 
1063
1455
  // Resolution order: built-in group, exact SDK name, then your own tools.
1064
1456
  // Built-ins win a bare name, because their names are a small fixed set —
@@ -1070,11 +1462,12 @@ function agentContext(
1070
1462
  } else if (TOOL_MAP[toolName]) {
1071
1463
  allowed.push(...TOOL_MAP[toolName]);
1072
1464
  if (available[toolName]) shadowed.push(toolName);
1073
- } else if (toolName === "search" || toolName === "history" || toolName === "desks") {
1465
+ } else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
1074
1466
  // The platform's own groups, served in-process — built below, once
1075
1467
  // the roots and the records are known.
1076
1468
  if (toolName === "search") wantSearch = true;
1077
1469
  else if (toolName === "history") wantHistory = true;
1470
+ else if (toolName === "ask") wantAsk = true;
1078
1471
  else wantDesks = true;
1079
1472
  if (available[toolName]) shadowed.push(toolName);
1080
1473
  } else if (BUILTIN_TOOLS.has(toolName)) {
@@ -1097,10 +1490,10 @@ function agentContext(
1097
1490
  ? [
1098
1491
  { label: "knowledge/", dir: path.join(agentDir, "knowledge") },
1099
1492
  { label: "memory/", dir: path.join(agentDir, "memory") },
1100
- { label: "../../knowledge/", dir: path.join(wsRoot, "knowledge") },
1101
- { label: "../../memory/", dir: path.join(wsRoot, "memory") },
1102
- { label: "../../state/", dir: path.join(wsRoot, "state") },
1103
- { label: `../../${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
1493
+ { label: "workspace/knowledge/", dir: path.join(wsRoot, "knowledge") },
1494
+ { label: "workspace/memory/", dir: path.join(wsRoot, "memory") },
1495
+ { label: "workspace/state/", dir: path.join(wsRoot, "state") },
1496
+ { label: `workspace/${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
1104
1497
  { label: "outputs/", dir: path.join(agentDir, "outputs") },
1105
1498
  { label: "[library] knowledge/", dir: libraryDir(tenant, "knowledge") },
1106
1499
  { label: "[library] memory/", dir: libraryDir(tenant, "memory") },
@@ -1122,6 +1515,14 @@ function agentContext(
1122
1515
  const historyTools = wantHistory ? buildHistoryTools(historyDigest) : { server: null, toolNames: [], promptLines: [] };
1123
1516
  const deskTools = wantDesks ? buildDeskTools(deskDigest) : { server: null, toolNames: [], promptLines: [] };
1124
1517
  allowed.push(...searchTools.toolNames, ...historyTools.toolNames, ...deskTools.toolNames);
1518
+ // tools: [ask] — ask_person. Its server is built where the step runs (it
1519
+ // needs the step's emit, and in a pod the proxy's address); here only the
1520
+ // grant, the wait and the prompt's line.
1521
+ const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
1522
+ if (ask) {
1523
+ allowed.push(ASK_TOOL);
1524
+ parts.push(`# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
1525
+ }
1125
1526
  if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
1126
1527
  parts.push(`# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
1127
1528
  }
@@ -1142,12 +1543,16 @@ function agentContext(
1142
1543
  return {
1143
1544
  front,
1144
1545
  clockEnv,
1546
+ /** The zone, where it came from, and anything unreadable on the way —
1547
+ * reported by the step, not here, so it lands in the run's trace. */
1548
+ clock,
1145
1549
  systemPrompt: parts.join("\n\n"),
1146
1550
  allowed: finalAllowed,
1147
1551
  disabled,
1148
1552
  unknownTools,
1149
1553
  shadowed,
1150
1554
  legacyUse,
1555
+ retiredTools: retiredToolNames(front),
1151
1556
  mcpServers,
1152
1557
  mcpNames,
1153
1558
  apiTools,
@@ -1157,19 +1562,21 @@ function agentContext(
1157
1562
  // than using the servers built here.
1158
1563
  apiSpecs: apis,
1159
1564
  scriptSpecs,
1565
+ outward: outward || scriptSpecs.some((sc) => sc.outward),
1160
1566
  searchRoots,
1161
1567
  historyDigest,
1162
1568
  deskDigest,
1163
1569
  searchTools,
1164
1570
  historyTools,
1165
1571
  deskTools,
1572
+ ask,
1166
1573
  // `spec` is the MERGED declaration — the agent's own block or its
1167
1574
  // workspace's, plus every granted tool's. The isolated path builds its
1168
1575
  // runtime from this; passing the agent's frontmatter alone silently drops
1169
1576
  // what the tools declared, and a tool whose program needs a package it
1170
1577
  // asked for by name fails inside the container with "cannot find" while
1171
1578
  // the host path, which uses this same merged spec, works fine.
1172
- runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec },
1579
+ runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec, dispose: runtime.dispose },
1173
1580
  secretEnv: { ...runtime.env, ...secretEnv },
1174
1581
  formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
1175
1582
  providerEnv,
@@ -1177,8 +1584,19 @@ function agentContext(
1177
1584
  providerSecrets,
1178
1585
  providerWarnings,
1179
1586
  fallbackEnv,
1180
- translator,
1181
- fallbackTranslator,
1587
+ region,
1588
+ // Who answers web search and fetch, resolved once up top. The step
1589
+ // needs them again to grant a direct API's key to its host.
1590
+ searchChoice,
1591
+ fetchChoice,
1592
+ actionChoices,
1593
+ web,
1594
+ language,
1595
+ // Stamped with the step's own zone: when one of these refuses, the
1596
+ // reset time it reports is told in the calendar the step works to,
1597
+ // not in the pod's UTC.
1598
+ translator: translator && { ...translator, timezone },
1599
+ fallbackTranslator: fallbackTranslator && { ...fallbackTranslator, timezone },
1182
1600
  apiWarnings,
1183
1601
  secretScopes,
1184
1602
  missingTools,
@@ -1208,28 +1626,36 @@ function agentContext(
1208
1626
  * Never fails a step: a link is bookkeeping, and a run that did its work must
1209
1627
  * not be marked failed because a URL could not be minted.
1210
1628
  */
1211
- function publishPublicDir(
1629
+ async function publishPublicDir(
1212
1630
  tenant: string,
1213
1631
  workspace: string,
1214
1632
  push: (type: "info" | "error", text: string) => void,
1215
- ) {
1633
+ ): Promise<void> {
1216
1634
  try {
1217
- const { added } = platform.syncPublicShares(tenant, workspace);
1635
+ const { added } = await platform.syncPublicShares(tenant, workspace);
1218
1636
  if (added.length) push("info", `shared: ${added.join(", ")}`);
1219
1637
  } catch (err) {
1220
1638
  push("error", `shares: ${err instanceof Error ? err.message : String(err)}`);
1221
1639
  }
1222
1640
  }
1223
1641
 
1224
- function repriced(
1642
+ export function repriced(
1225
1643
  catalog: Catalog | null,
1226
1644
  model: string,
1227
1645
  usage: { inputTokens: number; outputTokens: number } | null,
1228
1646
  sdkCost: number | null,
1229
1647
  push: (type: "info" | "error", text: string) => void,
1648
+ /** The step's turns each priced at its own model — set when a sub-agent
1649
+ * ran on a different model (ExecOutcome.turnsCostUsd). The lump `usage`
1650
+ * priced at the step's model would bill those turns at the wrong rate. */
1651
+ turnsCostUsd?: number | null,
1230
1652
  ): number | null {
1231
1653
  const known = usage ? catalogCost(catalog, model, usage) : null;
1232
1654
  if (known === null) return sdkCost;
1655
+ if (typeof turnsCostUsd === "number") {
1656
+ push("info", `cost from each model turn at its own model's rate: $${turnsCostUsd.toFixed(6)} (sub-agents ran on other models)`);
1657
+ return turnsCostUsd;
1658
+ }
1233
1659
  if (sdkCost !== null && Math.abs(known - sdkCost) > 0.000001) {
1234
1660
  push("info", `cost repriced from the gateway's catalogue: $${known.toFixed(6)} (sdk said $${sdkCost.toFixed(6)})`);
1235
1661
  }
@@ -1248,7 +1674,8 @@ function repriced(
1248
1674
  */
1249
1675
  export function recordAttempt(
1250
1676
  step: StepRecord,
1251
- n: number,
1677
+ /** The step's attempt (`attempts`) these rows belong to. */
1678
+ attempt: number,
1252
1679
  status: "completed" | "failed",
1253
1680
  startedAt: string,
1254
1681
  eventsBefore: number,
@@ -1261,24 +1688,74 @@ export function recordAttempt(
1261
1688
  .slice(eventsBefore)
1262
1689
  .filter((e) => e.type === "error")
1263
1690
  .at(-1)?.text;
1691
+ // Re-attached: the same attempt, so its rows — the last one, and the lost
1692
+ // slim go before it — are replaced, not repeated.
1693
+ const kept = [...tries];
1694
+ const attemptOf = (t: StepAttempt) => t.attempt ?? t.n;
1695
+ if (reattach && kept.length && attemptOf(kept.at(-1)!) === attempt) {
1696
+ kept.pop();
1697
+ if (kept.length && attemptOf(kept.at(-1)!) === attempt && kept.at(-1)!.status === "lost") kept.pop();
1698
+ }
1699
+ // Tries are numbered in the order they ran: a full re-run after a lost
1700
+ // slim go is the next try, not the same number again (live, 2026-10-01,
1701
+ // both rows read n: 1). `attempt` says which attempt a row belongs to
1702
+ // when its number no longer does.
1703
+ let next = (kept.at(-1)?.n ?? 0) + 1;
1704
+ const numbered = () => {
1705
+ const n = next++;
1706
+ return n === attempt ? { n } : { n, attempt };
1707
+ };
1708
+ // A slim go the browser pod was lost under, before this attempt re-ran
1709
+ // on the full image: its own row, first (runStep set it aside).
1710
+ const lost: StepAttempt[] = step.podLostTry ? [{ ...numbered(), ...step.podLostTry, startedAt }] : [];
1264
1711
  const row: StepAttempt = {
1265
- n,
1712
+ ...numbered(),
1266
1713
  status,
1267
1714
  costUsd: step.costUsd,
1268
1715
  tokens: step.tokens ?? null,
1269
1716
  computeSecs: step.computeSecs ?? null,
1270
- startedAt,
1717
+ startedAt: step.podLostTry?.finishedAt ?? startedAt,
1271
1718
  finishedAt: step.finishedAt ?? new Date().toISOString(),
1272
1719
  ...(status === "failed" && lastError ? { error: lastError.slice(0, 500) } : {}),
1720
+ ...(step.image?.variant ? { image: step.image.variant } : {}),
1721
+ ...(step.browserPod ? { browserPod: browserPodLine(step.browserPod) } : {}),
1273
1722
  };
1274
- const kept = reattach && tries.at(-1)?.n === n ? tries.slice(0, -1) : tries;
1275
- step.tries = [...kept, row];
1723
+ delete step.podLostTry;
1724
+ step.tries = [...kept, ...lost, row];
1276
1725
  const totals = sumAttempts(step.tries);
1277
1726
  step.costUsd = totals.costUsd;
1278
1727
  step.tokens = totals.tokens;
1279
1728
  step.computeSecs = totals.computeSecs;
1280
1729
  }
1281
1730
 
1731
+ /** A question an agent asked mid-step goes where approvals go (the
1732
+ * workspace's notify:, `awaiting-approval` events), with the way to answer. */
1733
+ async function notifyQuestion(tenant: string, workspace: string, runId: string, agent: string, question: string, options?: string[]): Promise<void> {
1734
+ const origin = (process.env.FOLDRUN_PUBLIC_URL ?? "").replace(/\/+$/, "");
1735
+ const where = origin ? `${origin}/dashboard/${encodeURIComponent(workspace)}/runs/${encodeURIComponent(runId)}${tenant !== "default" ? `?tenant=${encodeURIComponent(tenant)}` : ""}` : `run ${runId}`;
1736
+ await sendPlainNotification(tenant, workspace, {
1737
+ event: "awaiting-approval",
1738
+ headline: `${agent} is asking you something`,
1739
+ detail: `${question}${options?.length ? `\n\nOptions: ${options.join(" · ")}` : ""}\n\nAnswer it on ${where}, or \`foldrun answer ${runId} "…"\`.`,
1740
+ runId,
1741
+ }).catch(() => false);
1742
+ }
1743
+
1744
+ /** The local stand-in for a person: the terminal, when there is one. */
1745
+ async function terminalAsk(question: string, options?: string[]): Promise<string | null> {
1746
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
1747
+ const { createInterface } = await import("node:readline/promises");
1748
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1749
+ try {
1750
+ const list = options?.length ? `\n${options.map((o, i) => ` ${i + 1}. ${o}`).join("\n")}\n` : "";
1751
+ const raw = (await rl.question(`\n the agent asks: ${question}${list}\n your answer: `)).trim();
1752
+ const n = Number(raw);
1753
+ return options?.length && Number.isInteger(n) && n >= 1 && n <= options.length ? options[n - 1] : raw || null;
1754
+ } finally {
1755
+ rl.close();
1756
+ }
1757
+ }
1758
+
1282
1759
  async function runStep(
1283
1760
  agentDir: string,
1284
1761
  tenant: string,
@@ -1305,6 +1782,13 @@ async function runStep(
1305
1782
  * in-process model loop, so a stop lands mid-step and not at the next
1306
1783
  * group. The isolated path needs none: stopRun destroys the sandbox. */
1307
1784
  stopRequested?: () => boolean,
1785
+ /** The flow's `timezone:`, for the clock cascade. Undefined for a step
1786
+ * run outside any flow, which still gets agent → workspace → account. */
1787
+ flowTimezone?: string | null,
1788
+ /** The step's index in run.steps, carried on its egress lease so a
1789
+ * person's message to this step reaches this step's own inbox and not a
1790
+ * sibling's running in the same group. Undefined outside a flow. */
1791
+ stepIndex?: number,
1308
1792
  ) {
1309
1793
  // Secret values are injected into scripts as environment variables and
1310
1794
  // substituted into API headers, so a model that reads one back — from a
@@ -1323,6 +1807,11 @@ async function runStep(
1323
1807
  // cover a script's own stdout.
1324
1808
  let redactions: [string, string][] = [];
1325
1809
  let fileDir: string | null = null;
1810
+ let disposeRuntime: (() => void) | undefined;
1811
+ // Can this step's tools act outside the workspace? Set once its context is
1812
+ // read; the verdict at the end of this function reads it.
1813
+ let canActOutward = false;
1814
+ const eventsAtStart = step.events.length;
1326
1815
  const redact = (text: string) => {
1327
1816
  let out = text;
1328
1817
  for (const [value, name] of redactions) out = out.split(value).join(`[redacted:${name}]`);
@@ -1346,7 +1835,16 @@ async function runStep(
1346
1835
  };
1347
1836
 
1348
1837
  const push = (type: StepRecord["events"][number]["type"], text: string, extra?: EventExtra) => {
1349
- step.events.push({ t: new Date().toISOString(), type, text: redact(text), ...extra });
1838
+ const t = new Date().toISOString();
1839
+ step.events.push({ t, type, text: redact(text), ...extra });
1840
+ // A person in the loop: the question, its answer, a message delivered —
1841
+ // onto the step, so the run page, the API and history show them.
1842
+ if (extra?.operator) {
1843
+ applyOperatorEvent(step, extra.operator, t);
1844
+ if (extra.operator.kind === "asked" && runId && !testRun) {
1845
+ void notifyQuestion(tenant, path.basename(path.resolve(agentDir, "..", "..")), runId, step.agent, extra.operator.question, extra.operator.options);
1846
+ }
1847
+ }
1350
1848
  save();
1351
1849
  };
1352
1850
 
@@ -1366,7 +1864,9 @@ async function runStep(
1366
1864
  ? fs.readFileSync(stubFile, "utf8").split(/\n---\n/)
1367
1865
  : [`stub result from ${step.agent}`];
1368
1866
  const call = step.events.filter((e) => e.text.startsWith("stub call")).length;
1369
- push("info", `stub call ${call + 1}`);
1867
+ // The ceiling it was given rides on the line, so the budget arithmetic
1868
+ // around fan-out is testable without a model to stop.
1869
+ push("info", `stub call ${call + 1}${stepBudgetUsd !== null ? ` (ceiling $${stepBudgetUsd.toFixed(2)})` : ""}`);
1370
1870
  step.status = "completed";
1371
1871
  // A stub answer may open with `cost: 0.25` — a pretend price, so the
1372
1872
  // parts of orchestration that watch money (budget:) are testable too.
@@ -1409,14 +1909,19 @@ async function runStep(
1409
1909
  // step: the generic HTTP tool still works without the typed ones.
1410
1910
  for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant))) push("error", `openapi: ${w}`);
1411
1911
  const {
1412
- front, clockEnv, systemPrompt, allowed, disabled, apiTools, scriptTools,
1912
+ front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools,
1413
1913
  secretEnv, secretScopes, missingSecrets, missingTools, runtime,
1414
- unknownTools, shadowed, legacyUse, mcpServers, mcpNames,
1914
+ unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames,
1415
1915
  apiSpecs, scriptSpecs, brokenTools, size: agentSize,
1416
1916
  providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning,
1917
+ searchChoice, fetchChoice, actionChoices, web, language, region,
1417
1918
  fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools,
1919
+ ask,
1418
1920
  translator, fallbackTranslator,
1419
- } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent });
1921
+ outward,
1922
+ } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
1923
+ disposeRuntime = runtime.dispose;
1924
+ canActOutward = outward;
1420
1925
  // A retry that moved the step up a class (an evicted or OOM-killed
1421
1926
  // attempt) wins over the agent's own `size:` for the attempts after.
1422
1927
  const size = step.sizeUp ?? agentSize;
@@ -1442,15 +1947,34 @@ async function runStep(
1442
1947
  // Longest first, so a value containing another is replaced whole.
1443
1948
  .sort((a, b) => b[0].length - a[0].length);
1444
1949
 
1950
+ // One line for the clock: which zone this step works to, and which level
1951
+ // set it. "Why is this dated yesterday" should be answerable from the run.
1952
+ push("info", `clock: ${clock.timezone} (${clock.source}) — today is ${clockEnv.FOLDRUN_DATE}`);
1953
+ // And one more only when something written could not be read as a zone.
1954
+ for (const problem of clock.problems) push("error", problem);
1445
1955
  if (providerLabel) push("info", `provider: ${providerLabel}`);
1956
+ // Which credential the model is called with, on the record: an agent
1957
+ // that names a provider uses its own; otherwise the platform's (in a
1958
+ // run container) or this process's — never a login that just happens
1959
+ // to be on the machine (model-credential.ts).
1960
+ const credential = providerLabel ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
1961
+ step.credential = credential ? credential.label : `provider: ${providerLabel}`;
1962
+ if (credential) {
1963
+ const line = credentialLine(credential);
1964
+ push(line.type, line.text);
1965
+ }
1446
1966
  for (const w of providerWarnings) push("error", w);
1967
+ // A level that wrote a language nobody can read was skipped, not obeyed.
1968
+ for (const l of language.lines) push("info", l);
1969
+ for (const l of region.lines) push("info", l);
1447
1970
  for (const w of apiWarnings) push("error", `openapi: ${w}`);
1448
1971
  if (formatWarning) push("error", formatWarning);
1449
1972
  if (legacyUse.length) push("error", legacyUseError(legacyUse));
1973
+ for (const t of retiredTools) push("error", retiredToolError(t));
1450
1974
  for (const t of unknownTools) {
1451
1975
  push(
1452
1976
  "error",
1453
- `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`,
1977
+ `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`,
1454
1978
  );
1455
1979
  }
1456
1980
  for (const name of mcpNames) push("info", `mcp server connected: ${name}`);
@@ -1568,9 +2092,11 @@ async function runStep(
1568
2092
 
1569
2093
  // Expose the workspace's shared scripts/ as ./shared so every agent reaches
1570
2094
  // them by the same path, without each one carrying a copy.
1571
- // No symlinks into the agent directory — they would recurse into the
1572
- // workspace's own file listing. Shared paths are resolved by the script
1573
- // tool layer and described to the agent below.
2095
+ // No permanent symlinks into the agent directory — they would recurse
2096
+ // into the workspace's own file listing. Shared paths are resolved by the
2097
+ // script tool layer and described to the agent below; the one link there
2098
+ // is, `workspace` → the root for the shell, lives only for the length of
2099
+ // a step (executeStep, confine.ts#linkWorkspace).
1574
2100
 
1575
2101
  // The confinement boundary: agents in a workspace are one team, but the
1576
2102
  // workspace is where isolation is enforced.
@@ -1660,14 +2186,39 @@ async function runStep(
1660
2186
  for (const m of missingConsults) {
1661
2187
  push("error", `agents: "${m}" is not an agent in this workspace — no consult tool granted`);
1662
2188
  }
1663
- const consultNames = consults.length ? [...mcpNames, "foldrun_agents"] : mcpNames;
2189
+ const consultNames = [
2190
+ ...mcpNames,
2191
+ ...(consults.length ? ["foldrun_agents"] : []),
2192
+ ...(ask ? ["foldrun_ask"] : []),
2193
+ ];
2194
+
2195
+ // Colleagues this agent may DELEGATE to: each with its own context and
2196
+ // its own tools — resolved exactly as its own step's would be, then cut
2197
+ // to what THIS step holds (subagents.ts). Built host-side, like consults.
2198
+ const subagents = buildSubagentSpecs({
2199
+ names: front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
2200
+ parentAllowed: [...allowed, ...consultNames.map((n) => `mcp__${n}`)], push,
2201
+ });
2202
+ if (subagents.length) {
2203
+ // The delegation tool itself, granted only when there is someone to
2204
+ // delegate to.
2205
+ allowed.push(DELEGATE_TOOL);
2206
+ for (const s of subagents) push("info", `subagent: ${s.name} — ${s.tools.length ? s.tools.join(", ") : "no tools"}`);
2207
+ }
1664
2208
 
1665
2209
  // The last provider refusal this step saw, for the fallback decision —
1666
2210
  // watched on both paths, because a refusal reads the same from a pod
1667
2211
  // and from this process.
1668
2212
  let lastRefusal = "";
2213
+ // And WHY it refused, in words, when anything on the way saw the
2214
+ // headers that say so: the translator here, the egress proxy over
2215
+ // there. Kept apart from `lastRefusal` — that one decides whether to
2216
+ // fall back, this one is what a person reads afterwards.
2217
+ let refusalWhy = "";
1669
2218
  const pushWatching: typeof push = (type, text, extra) => {
1670
2219
  if (type === "error" && isProviderRefusal(text)) lastRefusal = text;
2220
+ const why = refusalFromLine(text);
2221
+ if (why) refusalWhy = why;
1671
2222
  push(type, text, extra);
1672
2223
  };
1673
2224
  // The second supply: the block's own fallback when it declared one (a
@@ -1675,6 +2226,28 @@ async function runStep(
1675
2226
  // fallback), else the platform's, and only for a step riding the
1676
2227
  // platform credential.
1677
2228
  const secondSupply = fallbackEnv ?? (Object.keys(providerEnv).length === 0 ? platformFallbackEnv() : null);
2229
+ // Where this step stands on a second supply, for the refusal sentence.
2230
+ // It starts as the truth before anything has run and becomes
2231
+ // "exhausted" the moment the fallback has had its turn — the owner is
2232
+ // about to remove the OpenRouter fallback, so "none" becomes the normal
2233
+ // reading and has to look like a decision on the page.
2234
+ let supplyState: SupplyState = secondSupply ? "trying" : "none";
2235
+ // The line a person reads when this step ends failed: the reason, if
2236
+ // anything on the way learned one, else the status put into words, and
2237
+ // in every case where the step could go next. Pushed last so it is the
2238
+ // last error event — which is what runFailure, the desks' headlines and
2239
+ // `foldrun report` read.
2240
+ const sayWhy = (status: StepRecord["status"]) => {
2241
+ if (status !== "failed" || !lastRefusal) return;
2242
+ const said = Number(lastRefusal.match(/API Error: (\d{3})/)?.[1] ?? NaN);
2243
+ const why =
2244
+ refusalWhy ||
2245
+ explainRefusal({
2246
+ status: Number.isFinite(said) && isRefusalStatus(said) ? said : 402,
2247
+ timezone: clock.timezone,
2248
+ });
2249
+ push("error", `${why}${supplyNote(supplyState)}`);
2250
+ };
1678
2251
 
1679
2252
  // `schema:` — inline on the step, or a file under the workspace read
1680
2253
  // now, host-side, so both executors are handed the same document. A
@@ -1685,6 +2258,18 @@ async function runStep(
1685
2258
  throw new Error(`schema: ${step.schemaPath} — not a readable JSON or YAML schema under the workspace`);
1686
2259
  }
1687
2260
  if (step.maxTurns) push("info", `max_turns: ${step.maxTurns}`);
2261
+ // `limits:` — account, workspace, agent, then this step's option;
2262
+ // nearest wins per key. Enforced in the model loop's hook, on either
2263
+ // path (step-exec.ts), so it is resolved here as plain values.
2264
+ const stepLimits = cascadeLimits([
2265
+ readAgentsMd(accountDir(tenant))?.data?.limits,
2266
+ readAgentsMd(workspaceRootOf(agentDir))?.data?.limits,
2267
+ front.limits,
2268
+ step.limits,
2269
+ ]);
2270
+ const limitOwners = toolOwners(apiTools.owners, scriptSpecs);
2271
+ const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
2272
+ if (limitOpts.limits) push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
1688
2273
 
1689
2274
  const isolation = process.env.FOLDRUN_RUN_ISOLATION;
1690
2275
  if (isolatedRun()) {
@@ -1708,7 +2293,18 @@ async function runStep(
1708
2293
  // before anything is copied in. Null where no proxy is configured
1709
2294
  // (the CLI, a compose install without FOLDRUN_EGRESS_URL), and then
1710
2295
  // everything below materialises exactly as it always did.
1711
- const grant: EgressGrant = { secrets: {} };
2296
+ const grant: EgressGrant = { secrets: {}, timezone: clock.timezone };
2297
+ // A direct search API's key: granted to that API's host and no other,
2298
+ // exactly as an http tool's key is granted to its base URL's host. The
2299
+ // script sends the placeholder; the worker fills it on the way out.
2300
+ for (const choice of [searchChoice, fetchChoice]) {
2301
+ if (choice.shape === "direct" && choice.secret && choice.host && choice.secret in liveSecrets) {
2302
+ addGrant(grant, choice.secret, liveSecrets[choice.secret], choice.host);
2303
+ }
2304
+ }
2305
+ for (const c of actionChoices) {
2306
+ if (c.provider && c.secret in liveSecrets) addGrant(grant, c.secret, liveSecrets[c.secret], c.host);
2307
+ }
1712
2308
  for (const api of apiSpecs) {
1713
2309
  const host = hostOf(api.base);
1714
2310
  if (!host) continue;
@@ -1726,13 +2322,16 @@ async function runStep(
1726
2322
  }
1727
2323
  }
1728
2324
  }
1729
- const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun });
2325
+ const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
1730
2326
  // Which secrets must still be real inside the sandbox: a script tool
1731
2327
  // reads them from its environment, and unless it says `secrets:
1732
2328
  // proxied` it needs the value, not the name. bash is a script by
1733
2329
  // another route. The record says which way it went, and why.
1734
2330
  const materialisers = [
1735
2331
  ...scriptSpecs.filter((sc) => sc.secrets !== "proxied").map((sc) => sc.name),
2332
+ // web proxies its keys — unless it may browse: a remote browser's key
2333
+ // goes into a websocket, and cookie and login secrets into the page.
2334
+ ...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
1736
2335
  ...(allowed.includes("Bash") ? ["bash"] : []),
1737
2336
  ];
1738
2337
  const materialise = !lease || materialisers.length > 0;
@@ -1810,7 +2409,7 @@ async function runStep(
1810
2409
  libraryRoot: libraryDir(tenant),
1811
2410
  galleryRoot: platform.galleryDir() ?? undefined,
1812
2411
  // Keys this step's dependency cache. Per-account by design — see
1813
- // run-container.ts#runtimeCacheDir for why it is not shared.
2412
+ // run-container.ts#runtimeCacheMount for why it is not shared.
1814
2413
  tenant,
1815
2414
  input: {
1816
2415
  agentRel: path.relative(workspaceRoot, agentDir).replaceAll("\\", "/"),
@@ -1827,6 +2426,7 @@ async function runStep(
1827
2426
  // field. A tool's own `runtime:` is part of what this step needs.
1828
2427
  runtime: runtime.spec,
1829
2428
  consults,
2429
+ ...(subagents.length ? { subagents } : {}),
1830
2430
  timeoutSec: step.timeout,
1831
2431
  budgetUsd: stepBudgetUsd,
1832
2432
  budgetNote: stepBudgetNote,
@@ -1835,7 +2435,12 @@ async function runStep(
1835
2435
  output: step.output,
1836
2436
  schema: resolvedSchema ?? undefined,
1837
2437
  maxTurns: step.maxTurns,
2438
+ ...limitOpts,
1838
2439
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
2440
+ // Outward steps keep a browser of their own: an executor that
2441
+ // could browse from the slim image through the account's pod
2442
+ // leaves them on the full one (browser-pod.ts).
2443
+ ...(outward ? { outward: true } : {}),
1839
2444
  // The container sees the workspace at /workspace and the library
1840
2445
  // at /library; the roots are named host-side and moved here.
1841
2446
  search: searchRoots.map((r) => ({
@@ -1846,6 +2451,7 @@ async function runStep(
1846
2451
  })),
1847
2452
  history: historyDigest,
1848
2453
  desks: deskDigest,
2454
+ ...(ask ? { ask } : {}),
1849
2455
  },
1850
2456
  env: Object.fromEntries(
1851
2457
  Object.entries({
@@ -1897,8 +2503,38 @@ async function runStep(
1897
2503
  // attempt holds its own sandbox, so the meter owes the sum.
1898
2504
  const firstArgs = isolatedArgs(platformModelEnv());
1899
2505
  await lease?.commit();
1900
- let outcome = await runIsolated(firstArgs);
2506
+ let credentialUsed = platformModelCredential();
2507
+ // The arguments of the attempt whose outcome is current — what a
2508
+ // re-run on the full image repeats, on the same model supply.
2509
+ let lastArgs: typeof firstArgs = firstArgs;
2510
+ const runTracked = (a: typeof firstArgs) => {
2511
+ lastArgs = a;
2512
+ return runIsolated(a);
2513
+ };
2514
+ let outcome = await runTracked(firstArgs);
1901
2515
  step.sandbox = null; // whatever happens next starts its own
2516
+ // A 401 from a token that rotated mid-step is not a broken key: the
2517
+ // replacement is already on its way to the mounted file. Wait for it
2518
+ // once, then fall into the same retry the busy case uses.
2519
+ if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
2520
+ push("info", `the model credential was refused mid-step — waiting for the refreshed one`);
2521
+ const rotated = await awaitRotatedCredential(credentialUsed);
2522
+ if (rotated) {
2523
+ push("info", "the credential rotated under this step; retrying on the new one");
2524
+ credentialUsed = rotated;
2525
+ lastRefusal = "";
2526
+ const previous = outcome.timing;
2527
+ // Build first: isolatedArgs is what re-grants the model key, and
2528
+ // commit() is what publishes the grant to Redis, where the proxy
2529
+ // reads it. Committing first sent the revoked token again — every
2530
+ // rotation retry on 2026-09-22/23 401'd this way.
2531
+ const retry = isolatedArgs(platformModelEnv());
2532
+ await lease?.commit();
2533
+ outcome = withEarlierTiming(await runTracked(retry), previous);
2534
+ } else {
2535
+ push("info", "the credential did not change — this is the key itself, not a rotation");
2536
+ }
2537
+ }
1902
2538
  for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
1903
2539
  const wait = backoffMs(n, lastRefusal);
1904
2540
  push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
@@ -1909,7 +2545,7 @@ async function runStep(
1909
2545
  const previous = outcome.timing;
1910
2546
  const again = isolatedArgs(platformModelEnv());
1911
2547
  await lease?.commit();
1912
- outcome = await runIsolated(again);
2548
+ outcome = await runTracked(again);
1913
2549
  outcome = withEarlierTiming(outcome, previous);
1914
2550
  }
1915
2551
  // The second supply, tried exactly once, and only when the primary
@@ -1937,22 +2573,70 @@ async function runStep(
1937
2573
  ),
1938
2574
  };
1939
2575
  await lease?.commit();
1940
- outcome = await runIsolated(fallbackArgs);
2576
+ supplyState = "exhausted";
2577
+ outcome = await runTracked(fallbackArgs);
1941
2578
  // Both attempts held sandboxes; the meter owes the sum. The first
1942
2579
  // try's pod ran, was billed for by the platform, and must not
1943
2580
  // vanish from the record because a second try replaced its outcome.
1944
2581
  outcome = withEarlierTiming(outcome, first);
1945
2582
  }
2583
+ // Slim browsing (browser-pod.ts): the step ran on the slim image and
2584
+ // the account's browser pod went away under it, past the web tool's
2585
+ // reconnects. Reads only — run it again from the start on the full
2586
+ // image, where a browser in the step is always there. Anything
2587
+ // written — fail it, naming what, and leave retry:/on-fail:/a person
2588
+ // to decide; a second go could send or charge twice.
2589
+ step.image = outcome.image ?? null;
2590
+ step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
2591
+ if (outcome.status === "failed" && outcome.browserPod?.lost) {
2592
+ const decision = podLossDecision(outcome.browserPod);
2593
+ if (decision.rerun) {
2594
+ push("info", decision.note);
2595
+ const first = outcome;
2596
+ // The slim go is a try of its own on the record — image slim,
2597
+ // status lost, its own cost, tokens and sandbox seconds — so the
2598
+ // run page and `foldrun report` show two tries, slim (lost) then
2599
+ // full, and the step's totals are the sum of both rows
2600
+ // (recordAttempt). Live, 2026-10-01, the step showed only the
2601
+ // full re-run.
2602
+ step.podLostTry = {
2603
+ status: "lost",
2604
+ costUsd: repriced(catalog, wireModel, first.usage ?? null, first.costUsd, push, first.turnsCostUsd),
2605
+ tokens: first.usage ? { input: first.usage.inputTokens, output: first.usage.outputTokens } : null,
2606
+ computeSecs: first.timing ? Math.round(first.timing.totalMs) / 1000 : null,
2607
+ finishedAt: new Date().toISOString(),
2608
+ error: decision.note.slice(0, 500),
2609
+ image: "slim",
2610
+ browserPod: browserPodLine(first.browserPod!),
2611
+ };
2612
+ const again = { ...lastArgs, input: { ...lastArgs.input, image: "full" as const }, resume: null };
2613
+ await lease?.commit();
2614
+ // Before the call, not after: a re-run that throws (the full
2615
+ // sandbox never started) is still the full go on the record — its
2616
+ // failed row read "slim" when this was set only on success.
2617
+ step.image = { variant: "full", why: "re-run after the browser pod was lost" };
2618
+ step.browserPod = { ...first.browserPod!, fallback: RERAN_ON_FULL };
2619
+ outcome = await runTracked(again);
2620
+ step.image = outcome.image ?? step.image;
2621
+ } else {
2622
+ push("error", decision.message);
2623
+ step.browserPod = { ...outcome.browserPod, failure: decision.message };
2624
+ }
2625
+ }
1946
2626
  if (lease) {
1947
2627
  for (const line of await lease.drainLog()) {
1948
- if (typeof line === "string") push("info", line);
1949
- else push("info", line.text, { effect: line.effect });
2628
+ // pushWatching, not push: a line the proxy wrote may carry the
2629
+ // refusal mark, and that sentence is the whole reason the proxy
2630
+ // reads response headers at all.
2631
+ if (typeof line === "string") pushWatching("info", line);
2632
+ else pushWatching("info", line.text, { effect: line.effect });
1950
2633
  }
1951
2634
  await lease.release();
1952
2635
  }
1953
2636
  // Nothing a test run left in storage/public/ reached storage/, so
1954
2637
  // there is nothing to publish — and a share link is an outward act.
1955
- if (!testRun) publishPublicDir(tenant, path.basename(workspaceRoot), push);
2638
+ if (!testRun) await publishPublicDir(tenant, path.basename(workspaceRoot), push);
2639
+ sayWhy(outcome.status);
1956
2640
  step.status = outcome.status;
1957
2641
  step.result = redactText(outcome.result);
1958
2642
  step.conclusion = redactText(outcome.conclusion);
@@ -1961,7 +2645,7 @@ async function runStep(
1961
2645
  // so — OOMKilled, Evicted — on the record, where the retry reads it.
1962
2646
  if (outcome.status === "failed" && outcome.reason) push("error", `sandbox ended: ${outcome.reason}`);
1963
2647
  if (outcome.data !== undefined) step.data = redactData(outcome.data);
1964
- step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push);
2648
+ step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push, outcome.turnsCostUsd);
1965
2649
  step.tokens = outcome.usage
1966
2650
  ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
1967
2651
  : null;
@@ -2006,11 +2690,18 @@ async function runStep(
2006
2690
  push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
2007
2691
  }
2008
2692
  const stepSecrets = held.env;
2693
+ // The model credential for this machine's Claude Code — the agent's
2694
+ // own provider when it names one. The host allowlist drops it, so
2695
+ // without this every in-process step ran on the machine's login.
2696
+ const localModelEnv: Record<string, string> = Object.keys(providerEnv).length ? providerEnv : resolveModelCredential().env;
2009
2697
  const consultTools = buildConsultTools(
2010
2698
  consults,
2011
- { ...hostSafeEnv(), ...stepSecrets, ...providerEnv },
2699
+ { ...hostSafeEnv(), ...stepSecrets, ...localModelEnv },
2012
2700
  (type, text) => push(type, text),
2013
2701
  );
2702
+ // On this machine nobody can write in and there is no proxy: the
2703
+ // question goes to the terminal when there is one, else unanswered.
2704
+ const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
2014
2705
  // @file secrets become 0600 paths here (host run), cleaned up in the
2015
2706
  // finally — once, however many attempts follow.
2016
2707
  const mat = materializeFileSecrets(agentDir, stepSecrets);
@@ -2019,6 +2710,7 @@ async function runStep(
2019
2710
  agentDir,
2020
2711
  workspaceRoot,
2021
2712
  libraryRoot: libraryDir(tenant),
2713
+ ...(subagents.length ? { subagents } : {}),
2022
2714
  prompt,
2023
2715
  model,
2024
2716
  effort,
@@ -2032,6 +2724,7 @@ async function runStep(
2032
2724
  ...(searchTools.server ? { foldrun_search: searchTools.server } : {}),
2033
2725
  ...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
2034
2726
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
2727
+ ...(askTools ? { foldrun_ask: askTools.server } : {}),
2035
2728
  ...mcpServers,
2036
2729
  },
2037
2730
  // Declared secrets reach the agent's scripts as env vars; the model
@@ -2048,6 +2741,7 @@ async function runStep(
2048
2741
  output: step.output,
2049
2742
  schema: resolvedSchema ?? undefined,
2050
2743
  maxTurns: step.maxTurns,
2744
+ ...limitOpts,
2051
2745
  stopRequested,
2052
2746
  // What the scripts saw, the verify sees: a flow can then check that
2053
2747
  // the proof a step left names THIS run, not one that came before —
@@ -2071,21 +2765,40 @@ async function runStep(
2071
2765
  // under the run and the originals come back.
2072
2766
  const attemptThrough = async (modelEnv: Record<string, string | undefined>, spec: TranslatorSpec | null) => {
2073
2767
  const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
2768
+ // What earlier steps of this test run wrote, laid over the real files
2769
+ // for this attempt; restoreDivertedDirs puts the originals back.
2770
+ const handed =
2771
+ before && runId && overlayTestWrites(workspaceRoot, divertFor(runId).to).length ? snapshotDivertedDirs(workspaceRoot) : before;
2074
2772
  const t = spec ? await startTranslator(spec) : null;
2075
2773
  try {
2076
2774
  return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
2077
2775
  } finally {
2078
2776
  if (t) {
2079
- for (const line of t.drainLog()) push("info", line.startsWith("translator") ? line : `translator: ${line}`);
2777
+ for (const line of t.drainLog()) pushWatching("info", line.startsWith("translator") ? line : `translator: ${line}`);
2080
2778
  await t.close();
2081
2779
  }
2082
2780
  if (before && runId) {
2083
2781
  const d = divertFor(runId);
2084
- restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
2782
+ restoreDivertedDirs(workspaceRoot, before, d.to, d.note, handed ?? before);
2085
2783
  }
2086
2784
  }
2087
2785
  };
2088
- let outcome = await attemptThrough(providerEnv, translator);
2786
+ let credentialUsed = platformModelCredential();
2787
+ let outcome = await attemptThrough(localModelEnv, translator);
2788
+ // Same rule as the isolated path: a credential that rotated under the
2789
+ // step is waited out and retried before anything else is considered.
2790
+ if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
2791
+ push("info", `the model credential was refused mid-step — waiting for the refreshed one`);
2792
+ const rotated = await awaitRotatedCredential(credentialUsed);
2793
+ if (rotated) {
2794
+ push("info", "the credential rotated under this step; retrying on the new one");
2795
+ credentialUsed = rotated;
2796
+ lastRefusal = "";
2797
+ outcome = await attemptThrough(localModelEnv, translator);
2798
+ } else {
2799
+ push("info", "the credential did not change — this is the key itself, not a rotation");
2800
+ }
2801
+ }
2089
2802
  // Same rule as the isolated path: a busy provider is waited out on
2090
2803
  // the supply it refused from, before any fallback is considered.
2091
2804
  for (let n = 1; n <= OVERLOAD_RETRIES && outcome.status === "failed" && isTransientOverload(lastRefusal); n++) {
@@ -2093,7 +2806,7 @@ async function runStep(
2093
2806
  push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
2094
2807
  await sleep(wait);
2095
2808
  lastRefusal = "";
2096
- outcome = await attemptThrough(providerEnv, translator);
2809
+ outcome = await attemptThrough(localModelEnv, translator);
2097
2810
  }
2098
2811
  // Same rule as the isolated path: one retry, on a refusal, on the
2099
2812
  // declared or platform second supply.
@@ -2102,16 +2815,18 @@ async function runStep(
2102
2815
  "info",
2103
2816
  `primary model supply refused (${lastRefusal.slice(0, 80)}) — retrying this step on the fallback provider`,
2104
2817
  );
2818
+ supplyState = "exhausted";
2105
2819
  outcome = await attemptThrough(secondSupply, fallbackEnv && secondSupply === fallbackEnv ? fallbackTranslator : null);
2106
2820
  }
2107
- if (!testRun) publishPublicDir(tenant, path.basename(workspaceRoot), push);
2821
+ if (!testRun) await publishPublicDir(tenant, path.basename(workspaceRoot), push);
2822
+ sayWhy(outcome.status);
2108
2823
  step.status = outcome.status;
2109
2824
  step.result = redactText(outcome.result);
2110
2825
  step.conclusion = redactText(outcome.conclusion);
2111
2826
  if (outcome.data !== undefined) step.data = redactData(outcome.data);
2112
2827
  // A consult's spend belongs to the step that asked.
2113
2828
  const consultCost = consultTools.drainCost();
2114
- const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push);
2829
+ const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push, outcome.turnsCostUsd);
2115
2830
  step.costUsd = stepCost === null && consultCost === 0 ? null : (stepCost ?? 0) + consultCost;
2116
2831
  step.tokens = outcome.usage
2117
2832
  ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
@@ -2128,9 +2843,33 @@ async function runStep(
2128
2843
  } catch {
2129
2844
  // best-effort — a leftover scratch file is swept with the run's outputs
2130
2845
  }
2846
+ try {
2847
+ // A private runtime build (see prepareRuntime) is this step's alone.
2848
+ disposeRuntime?.();
2849
+ } catch {
2850
+ // best-effort — it is in the tmpdir
2851
+ }
2131
2852
  }
2132
2853
  step.finishedAt = new Date().toISOString();
2133
2854
  save();
2855
+ return { actedThenFailedCheck: canActOutward && actedThenFailedCheck(step.events.slice(eventsAtStart)) };
2856
+ }
2857
+
2858
+ /**
2859
+ * Did this attempt call its tools and then fail only on the check after —
2860
+ * `verify:`, `output: json`, a schema? For a step whose tools act outward
2861
+ * that is the one failure a retry must not answer: the email went, the post
2862
+ * is up, and the check failing says nothing about whether they happened. A
2863
+ * second attempt sends them again. Read off the attempt's own events, which
2864
+ * both executors write the same way (step-exec.ts): every event a failed
2865
+ * check writes carries `check: true`. It used to read only the LAST error's
2866
+ * wording, and a failed shell verify or judge writes its detail after its
2867
+ * headline — so the step was retried and the email went twice. The wording
2868
+ * is still honoured, for a driver image older than the tag.
2869
+ */
2870
+ export function actedThenFailedCheck(events: { type: string; text: string; check?: boolean }[]): boolean {
2871
+ if (!events.some((e) => e.type === "tool")) return false;
2872
+ return events.some((e) => e.type === "error" && (e.check === true || /^(verify `|output: json — |schema: the value does not fit)/.test(e.text)));
2134
2873
  }
2135
2874
 
2136
2875
  // Block until a human approves or rejects the pending steps. Polls the run
@@ -2231,6 +2970,59 @@ function platformFallbackEnv(): Record<string, string> | null {
2231
2970
  return out;
2232
2971
  }
2233
2972
 
2973
+ /**
2974
+ * A 401 the token refresh caused, rather than a key that is simply wrong.
2975
+ *
2976
+ * The refresher on the box turns the long-lived grant into an 8h access
2977
+ * token every four hours, and Anthropic revokes the old one the instant the
2978
+ * new one is minted. The platform reads the credential per step from the
2979
+ * mounted file, which fixed the boot-time copy — but not this: a step
2980
+ * already talking to the API when the rotation lands gets a hard 401
2981
+ * mid-turn, and the replacement does not appear in the mounted file until
2982
+ * the kubelet syncs it, about a minute later.
2983
+ *
2984
+ * That window is narrow and it is not rare. Every 401 on this account in
2985
+ * three weeks of runs fell inside it: 02:07:22 and 02:07:24 against a 02:07
2986
+ * refresh, 22:08:12, 22:08:17, 22:08:30 and 22:09:03 against 22:08. The
2987
+ * desks that lost a run nightly were simply the ones scheduled on the hour
2988
+ * the refresher shares.
2989
+ *
2990
+ * So: on a 401, wait for the file to hold a DIFFERENT credential, then let
2991
+ * the caller retry with it. A token that never changes inside the window is
2992
+ * a key that is actually invalid — no waiting fixes that, and the caller
2993
+ * falls through to the second supply exactly as before. Moving the timer
2994
+ * cannot fix this; a run that takes forty minutes spans any minute chosen.
2995
+ */
2996
+ const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
2997
+ const CREDENTIAL_POLL_MS = 2_000;
2998
+
2999
+ function isAuthRefusal(text: string): boolean {
3000
+ return /API Error: 401|access token has (been revoked|expired)/i.test(text);
3001
+ }
3002
+
3003
+ /**
3004
+ * Wait, briefly, for the mounted credential to become one we have not
3005
+ * already been refused on. Returns the new value, or null when the window
3006
+ * passes without a change — which is the answer that means "this key is
3007
+ * wrong", not "this key is stale".
3008
+ */
3009
+ export async function awaitRotatedCredential(
3010
+ before: string | undefined,
3011
+ now: () => number = Date.now,
3012
+ read: () => string | undefined = () => platformModelCredential(),
3013
+ waitMs = CREDENTIAL_ROTATION_WAIT_MS,
3014
+ pollMs = CREDENTIAL_POLL_MS,
3015
+ ): Promise<string | null> {
3016
+ if (!before) return null;
3017
+ const until = now() + waitMs;
3018
+ for (;;) {
3019
+ const current = read();
3020
+ if (current && current !== before) return current;
3021
+ if (now() >= until) return null;
3022
+ await sleep(pollMs);
3023
+ }
3024
+ }
3025
+
2234
3026
  /** A model-call failure the fallback can answer: the primary refusing over
2235
3027
  * credit, quota or auth — never a failure of the work itself, which would
2236
3028
  * fail identically anywhere. */
@@ -2241,7 +3033,19 @@ function isProviderRefusal(text: string): boolean {
2241
3033
  // fallback it could only time out on again. A refusal the fallback can
2242
3034
  // answer is about the ACCOUNT; anything about the work or the sandbox is
2243
3035
  // not, however similar the vocabulary.
2244
- return /API Error: (401|402|403|429|5\d\d)|credits?\b|quota|billing|insufficient|overloaded/i.test(text);
3036
+ // A subscription's own ceiling is the same kind of refusal, worded
3037
+ // differently: Claude Code on a Max plan answers "You've hit your weekly
3038
+ // limit · resets 5am" with no status code and none of the money words.
3039
+ // On 2026-09-21 every opus-tier step on the account failed on exactly that
3040
+ // sentence while sonnet steps beside them ran fine — the plan has a
3041
+ // separate weekly cap for Opus — and nothing here recognised it, so a
3042
+ // second supply could not have answered even if one had been configured.
3043
+ // It is not transient (isTransientOverload is right to ignore it: the
3044
+ // window is a week), it is the account's supply being exhausted, which is
3045
+ // precisely what the fallback provider is for.
3046
+ 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(
3047
+ text,
3048
+ );
2245
3049
  }
2246
3050
 
2247
3051
  /** How many times a busy provider is given another moment, and the longest
@@ -2297,7 +3101,47 @@ export function backoffMs(attempt: number, text = "", random: () => number = Mat
2297
3101
  return Math.round(base * (0.5 + random() * 0.5));
2298
3102
  }
2299
3103
 
2300
- function platformModelEnv(): Record<string, string | undefined> {
3104
+ /**
3105
+ * The platform's model credential, read at the moment a step needs it.
3106
+ *
3107
+ * It used to come only from this process's environment, which meant the
3108
+ * worker held whatever token it was started with. Refreshing an OAuth token
3109
+ * REVOKES the old one, so a refresh without a restart left every run
3110
+ * answering 401 — on 2026-09-18 that took every desk down for two hours, and
3111
+ * nothing said so until a person read a failed run. The refresher's own
3112
+ * comment had named the fix years of incidents earlier: stop shipping the
3113
+ * token as an env var.
3114
+ *
3115
+ * So: when `FOLDRUN_MODEL_KEY_FILE` names a file (the Kubernetes secret,
3116
+ * mounted rather than injected), it is read fresh every time. The kubelet
3117
+ * updates a mounted secret in place within about a minute of it changing, so
3118
+ * a refreshed token reaches the next step on its own, with no restart, no
3119
+ * timing window, and nothing to skip while work is in flight.
3120
+ *
3121
+ * A missing or empty file falls back to the environment, which is what a
3122
+ * local install, the CLI and every compose setup use.
3123
+ */
3124
+ export function platformModelCredential(env: NodeJS.ProcessEnv = process.env): string | undefined {
3125
+ const file = env.FOLDRUN_MODEL_KEY_FILE;
3126
+ if (file) {
3127
+ try {
3128
+ const value = fs.readFileSync(file, "utf8").trim();
3129
+ if (value) return value;
3130
+ } catch {
3131
+ // Unreadable is not the same as absent: say so once, then fall back,
3132
+ // because a running platform that cannot read its credential file is a
3133
+ // thing an operator needs to know about before the next step fails.
3134
+ if (!credentialFileWarned) {
3135
+ credentialFileWarned = true;
3136
+ console.warn(`[model] FOLDRUN_MODEL_KEY_FILE=${file} could not be read; falling back to the environment`);
3137
+ }
3138
+ }
3139
+ }
3140
+ return env.CLAUDE_CODE_OAUTH_TOKEN || undefined;
3141
+ }
3142
+ let credentialFileWarned = false;
3143
+
3144
+ export function platformModelEnv(): Record<string, string | undefined> {
2301
3145
  const {
2302
3146
  ANTHROPIC_API_KEY,
2303
3147
  ANTHROPIC_AUTH_TOKEN,
@@ -2316,7 +3160,9 @@ function platformModelEnv(): Record<string, string | undefined> {
2316
3160
  ANTHROPIC_API_KEY,
2317
3161
  ANTHROPIC_AUTH_TOKEN,
2318
3162
  ANTHROPIC_BASE_URL,
2319
- CLAUDE_CODE_OAUTH_TOKEN,
3163
+ // The file wins when there is one: it is the live secret, and this
3164
+ // process's copy is whatever it was handed at boot.
3165
+ CLAUDE_CODE_OAUTH_TOKEN: platformModelCredential() ?? CLAUDE_CODE_OAUTH_TOKEN,
2320
3166
  ANTHROPIC_DEFAULT_HAIKU_MODEL,
2321
3167
  ANTHROPIC_DEFAULT_SONNET_MODEL,
2322
3168
  ANTHROPIC_DEFAULT_OPUS_MODEL,
@@ -2365,20 +3211,38 @@ function jsonItems(data: unknown, step: StepRecord, take: number): string[] {
2365
3211
  function csvItems(workspaceDir: string, step: StepRecord, take: number): string[] {
2366
3212
  const raw = step.eachPath ?? "";
2367
3213
  if (!raw) {
2368
- step.skipReason = "each: rows needs a path — `each: rows of ../../storage/x.csv`";
3214
+ step.skipReason = "each: rows needs a path — `each: rows of workspace/storage/x.csv`";
2369
3215
  return [];
2370
3216
  }
2371
- const resolved = path.resolve(workspaceDir, "agents", step.agent, raw);
2372
- if (!resolved.startsWith(path.resolve(workspaceDir) + path.sep)) {
2373
- step.skipReason = `each: rows — ${raw} is outside this workspace`;
3217
+ const read = csvDataRows(workspaceDir, step.agent, raw);
3218
+ if ("problem" in read) {
3219
+ step.skipReason = `each: rows — ${read.problem}`;
2374
3220
  return [];
2375
3221
  }
3222
+ return read.data.slice(0, take).map((r) => `${read.header}\n${r}`);
3223
+ }
3224
+
3225
+ /**
3226
+ * The data rows of a CSV, path agent-relative like every path in a flow and
3227
+ * confined to the workspace. Shared by `each: rows of`, which fans out over
3228
+ * them, and `when: rows of`, which asks only whether there are any.
3229
+ */
3230
+ function csvDataRows(
3231
+ workspaceDir: string,
3232
+ agent: string,
3233
+ raw: string,
3234
+ ): { header: string; data: string[] } | { problem: string } {
3235
+ // `workspace/storage/x.csv` from the workspace root, anything else from
3236
+ // the agent's folder (`../../storage/x.csv` is the same file).
3237
+ const resolved = resolveAgentPath(workspaceDir, path.join(workspaceDir, "agents", agent), raw);
3238
+ if (!resolved.startsWith(path.resolve(workspaceDir) + path.sep)) {
3239
+ return { problem: `${raw} is outside this workspace` };
3240
+ }
2376
3241
  let text: string;
2377
3242
  try {
2378
3243
  text = fs.readFileSync(resolved, "utf8");
2379
3244
  } catch {
2380
- step.skipReason = `each: rows — ${raw} does not exist (yet?)`;
2381
- return [];
3245
+ return { problem: `${raw} does not exist (yet?)` };
2382
3246
  }
2383
3247
  // Minimal CSV: quoted fields with embedded commas/newlines survive.
2384
3248
  const rows: string[] = [];
@@ -2395,24 +3259,21 @@ function csvItems(workspaceDir: string, step: StepRecord, take: number): string[
2395
3259
  }
2396
3260
  if (row + field) rows.push(row + field);
2397
3261
  const [header, ...data] = rows.filter((r) => r.trim() !== "");
2398
- if (!header || data.length === 0) {
2399
- step.skipReason = `each: rows — ${raw} has no data rows`;
2400
- return [];
2401
- }
2402
- return data.slice(0, take).map((r) => `${header}\n${r}`);
3262
+ if (!header || data.length === 0) return { problem: `${raw} has no data rows` };
3263
+ return { header, data };
2403
3264
  }
2404
3265
 
2405
3266
  /**
2406
3267
  * `schema: <path>` — the file, read and parsed, or null. Agent-relative like
2407
- * every other path in a flow (`../../schemas/lead.json`), confined to the
3268
+ * every other path in a flow (`workspace/schemas/lead.json`), confined to the
2408
3269
  * workspace: a schema is the author's document, never something a step
2409
3270
  * can point outside.
2410
3271
  */
2411
3272
  export function readSchemaFile(workspaceRoot: string, rel: string): Record<string, unknown> | boolean | null {
2412
3273
  const root = path.resolve(workspaceRoot);
2413
- // Written agent-relative (`../../schemas/x.json`) or workspace-relative
2414
- // (`schemas/x.json`); both land on the same file.
2415
- const candidates = [path.resolve(root, rel.replace(/^(\.\.\/){2}/, "")), path.resolve(root, "agents", "x", rel)];
3274
+ // Written `workspace/schemas/x.json`, agent-relative (`../../schemas/x.json`)
3275
+ // or workspace-relative (`schemas/x.json`); all land on the same file.
3276
+ const candidates = [path.resolve(root, rel.replace(/^((\.\.\/){2}|workspace\/)/, "")), path.resolve(root, "agents", "x", rel)];
2416
3277
  for (const abs of candidates) {
2417
3278
  if (abs !== root && !abs.startsWith(root + path.sep)) continue;
2418
3279
  if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
@@ -2472,6 +3333,7 @@ export function createFlowRun(
2472
3333
  schemaPath: s.schemaPath,
2473
3334
  parallel: s.parallel,
2474
3335
  maxTurns: s.maxTurns,
3336
+ limits: s.limits,
2475
3337
  attempts: 0,
2476
3338
  status: "pending",
2477
3339
  events: [],
@@ -2627,6 +3489,18 @@ function driveRunInner(
2627
3489
  opts: { parkOnApproval?: boolean; effortOverride?: string | null },
2628
3490
  ): Promise<void> {
2629
3491
  const effortOverride = opts.effortOverride ?? null;
3492
+ // The flow's own calendar. Read from the flow file rather than passed in,
3493
+ // so every door into a run — the queue, an eval, the CLI, a rerun, a
3494
+ // reconcile that picks an abandoned run back up — gets the same clock
3495
+ // without each caller having to remember it. A run that belongs to no
3496
+ // flow (an ad-hoc agent run) finds nothing here and cascades from the
3497
+ // agent to the workspace, which is what it should do.
3498
+ let flowTimezone: string | null = null;
3499
+ try {
3500
+ flowTimezone = readFlow(tenant, workspace, run.flow)?.timezone ?? null;
3501
+ } catch {
3502
+ // an unreadable flow file is reported where it is parsed for real
3503
+ }
2630
3504
  const pDir = workspaceDir(tenant, workspace);
2631
3505
  const runRoot = pDir;
2632
3506
  /**
@@ -3067,6 +3941,21 @@ function driveRunInner(
3067
3941
  // group's result.
3068
3942
  for (const step of freshGroup) {
3069
3943
  if (step.status !== "pending" || !step.when) continue;
3944
+ // `when: rows of <csv>` asks the data, not the prose: run only if
3945
+ // the file has a data row. It is the condition a fan-out's
3946
+ // followers need — an `each:` over an empty file skips itself but
3947
+ // leaves nothing for a marker to be read from, so the steps after
3948
+ // it used to run on nothing.
3949
+ const rowsOf = whenRowsPath(step.when);
3950
+ if (rowsOf !== null) {
3951
+ const read = csvDataRows(pDir, step.agent, rowsOf);
3952
+ if ("problem" in read) {
3953
+ step.status = "skipped";
3954
+ step.skipReason = `condition not met: when rows — ${read.problem}`;
3955
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `skipped — ${read.problem}` });
3956
+ }
3957
+ continue;
3958
+ }
3070
3959
  // A marker at the start of a line, not a word anywhere in the
3071
3960
  // prose — see markerPresent. "There are no BLOCKED items" must
3072
3961
  // not open a gate keyed on BLOCKED.
@@ -3240,18 +4129,27 @@ function driveRunInner(
3240
4129
  // An agent's own `budget:` — the most it may spend in one run — is
3241
4130
  // read at launch, so a cap raised mid-run applies to the next step.
3242
4131
  const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
4132
+ // The ceiling each attempt now running was given. A step records its
4133
+ // cost only when it ends, so while it runs its ceiling stands in for
4134
+ // what it may yet spend — otherwise every fan-out copy of one agent
4135
+ // saw none of its siblings' spend and got the agent's whole budget.
4136
+ const reserved = new Map<StepRecord, number>();
3243
4137
  // The ceiling for one attempt of one step, read NOW: what is left of
3244
4138
  // the run's budget shared across the group, or the agent's own cap
3245
- // less what its steps have cost. Computed before every attempt, not
3246
- // once before the loop — a retry's share is what is left after the
3247
- // failed attempt spent, and the step's own figure already carries
3248
- // that spend (it is the sum of its tries).
4139
+ // less what its steps have cost or hold in flight, shared with its
4140
+ // steps in this group still waiting to start. Computed before every
4141
+ // attempt, not once before the loop — a retry's share is what is left
4142
+ // after the failed attempt spent, and the step's own figure already
4143
+ // carries that spend (it is the sum of its tries).
3249
4144
  const ceilingFor = (step: StepRecord) =>
3250
4145
  stepCeilingFor(
3251
4146
  stepCeiling(run.budgetUsd, platform.runSpend(run), launching.length),
3252
4147
  agentBudgets.get(step.agent),
3253
- run.steps.filter((s) => s !== step && s.agent === step.agent).reduce((sum, s) => sum + (s.costUsd ?? 0), 0),
4148
+ run.steps
4149
+ .filter((s) => s.agent === step.agent)
4150
+ .reduce((sum, s) => sum + (s.costUsd ?? 0) + (s !== step && s.status === "running" ? (reserved.get(s) ?? 0) : 0), 0),
3254
4151
  step.agent,
4152
+ 1 + launching.filter((s) => s !== step && s.agent === step.agent && s.status === "pending" && !reserved.has(s)).length,
3255
4153
  );
3256
4154
  // parallel: — the instances of one fan-out template share a pool.
3257
4155
  // Keyed by what the instances share (agent + instruction), since
@@ -3312,8 +4210,9 @@ function driveRunInner(
3312
4210
  const eventsBefore = step.events.length;
3313
4211
  step.attempts = attempt;
3314
4212
  step.status = "running";
4213
+ if (ceiling.ceilingUsd !== null) reserved.set(step, ceiling.ceilingUsd);
3315
4214
  save();
3316
- await runStep(
4215
+ const verdict = await runStep(
3317
4216
  path.join(pDir, "agents", step.agent),
3318
4217
  tenant,
3319
4218
  step,
@@ -3333,17 +4232,49 @@ function driveRunInner(
3333
4232
  save();
3334
4233
  return run.stopRequested === true;
3335
4234
  },
3336
- );
4235
+ flowTimezone,
4236
+ run.steps.indexOf(step),
4237
+ ).finally(() => reserved.delete(step));
3337
4238
  // runStep mutates step.status; read it through a widened local
3338
4239
  // so TS doesn't keep the "running" narrowing from above.
3339
4240
  const outcome: string = step.status;
3340
4241
  recordAttempt(step, attempt, outcome === "completed" ? "completed" : "failed", attemptStartedAt, eventsBefore, reattach);
3341
4242
  save();
3342
- if (outcome !== "failed" || attempt === attempts) break;
3343
4243
  // A stop destroys the step's sandbox, which reads here as a
3344
- // failed attempt — and a failed attempt used to be retried in
3345
- // a fresh sandbox, so a stopped `retry: 2` step ran twice
3346
- // more. save() merges the flag from the record.
4244
+ // failed attempt. stopRun writes "stopped by a person" onto
4245
+ // this step in the record, and the save() above writes this
4246
+ // process's copy over it — so the trail ended on the
4247
+ // sandbox's own last words, "sandbox ended: Error (exit 143)",
4248
+ // and a person's stop read as the platform killing a healthy
4249
+ // step (abandon-desk, 2026-09-29: stopped from the CLI after
4250
+ // ten minutes, then chased as an unexplained kill). Say so,
4251
+ // whether or not a retry was left.
4252
+ if (outcome === "failed" && run.stopRequested) {
4253
+ step.events.push({
4254
+ t: new Date().toISOString(),
4255
+ type: "info",
4256
+ text: "stopped by a person mid-step — its sandbox was destroyed, which is what ended this attempt; not retried",
4257
+ });
4258
+ save();
4259
+ break;
4260
+ }
4261
+ if (outcome !== "failed" || attempt === attempts) break;
4262
+ // An outward step whose tools ran and whose check then failed
4263
+ // is not retried: the check cannot say the send did not
4264
+ // happen, and a retry would do it twice.
4265
+ if (verdict?.actedThenFailedCheck) {
4266
+ step.events.push({
4267
+ t: new Date().toISOString(),
4268
+ type: "info",
4269
+ 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)`,
4270
+ });
4271
+ save();
4272
+ break;
4273
+ }
4274
+ // A failed attempt used to be retried in a fresh sandbox even
4275
+ // after a stop, so a stopped `retry: 2` step ran twice more.
4276
+ // The check above ends the loop on a stop; this one covers a
4277
+ // stop that lands while the outward check was being written.
3347
4278
  save();
3348
4279
  if (run.stopRequested) break;
3349
4280
  // The retry policy. An attempt the cluster ended for want of
@@ -3435,7 +4366,7 @@ function driveRunInner(
3435
4366
  rescue.attempts = 1;
3436
4367
  rescue.status = "running";
3437
4368
  save();
3438
- await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true);
4369
+ await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, undefined, flowTimezone, run.steps.indexOf(rescue));
3439
4370
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
3440
4371
  const rescueOutcome: string = rescue.status;
3441
4372
  if (rescueOutcome === "completed") {
@@ -3593,6 +4524,9 @@ function driveRunInner(
3593
4524
  // learn, and the runs list and the notification stop having to say
3594
4525
  // only "completed".
3595
4526
  run.summary = runSummary(run);
4527
+ // Read before a test run's prefix goes on the summary, which would
4528
+ // otherwise hide the word it leads with.
4529
+ run.verdict = verdictOf(run.summary);
3596
4530
  // A test run's headline says so wherever the headline goes — the
3597
4531
  // runs list, the history tool, a later run's recall.
3598
4532
  if (run.test && run.summary) run.summary = testHeadline(run.summary);
@@ -3896,3 +4830,82 @@ export function runResult(run: RunRecord): string | null {
3896
4830
  }
3897
4831
  return null;
3898
4832
  }
4833
+
4834
+ /**
4835
+ * What a Docker-run script sees of the workspace and the account: their
4836
+ * scripts/ and their tools/, read-only. A folder tool's code lives in its
4837
+ * tools/<name>/ folder, not in scripts/ — without those two mounts every
4838
+ * folder tool (the hello template's wordcount among them) was handed a host
4839
+ * path the container did not have, and failed on every call. A gallery tool
4840
+ * nobody installed runs from the gallery's own shelf, so that is mounted too
4841
+ * when there is one. A folder that does not exist is skipped by the copy.
4842
+ */
4843
+ export function scriptMounts(agentDir: string, tenant: string): Record<string, string> {
4844
+ const workspaceRoot = path.resolve(agentDir, "..", "..");
4845
+ const mounts: Record<string, string> = {
4846
+ [path.join(workspaceRoot, "scripts")]: "/workspace-scripts",
4847
+ [path.join(workspaceRoot, "tools")]: "/workspace-tools",
4848
+ [libraryDir(tenant, "scripts")]: "/library-scripts",
4849
+ [libraryDir(tenant, "tools")]: "/library-tools",
4850
+ };
4851
+ const gallery = platform.galleryDir();
4852
+ if (gallery) mounts[path.join(gallery, "tools")] = "/gallery-tools";
4853
+ return mounts;
4854
+ }
4855
+
4856
+ /**
4857
+ * The skills an agent can see, nearest first, each with the path the agent
4858
+ * is told to read it at. Own skills, then the workspace's, then the
4859
+ * workspace's `.agents/skills/` (the cross-client convention, scanned after
4860
+ * the native location so a foldrun-native skill wins a name clash), then the
4861
+ * account library. The nearest definition of a name wins.
4862
+ *
4863
+ * The workspace's are written `workspace/skills/…`, the one spelling for the
4864
+ * workspace root, which the file tools expand themselves. `../../skills/…`
4865
+ * left the model to resolve it against a path it had to guess, and on the
4866
+ * hello template it climbed one level too far and was refused.
4867
+ */
4868
+ export function skillsInScope(agentDir: string, tenant: string): DiscoveredSkill[] {
4869
+ const skills: DiscoveredSkill[] = [];
4870
+ const seen = new Set<string>();
4871
+ const add = (dir: string, prefix: string, subdir = "skills") => {
4872
+ for (const skill of discoverSkills(dir, subdir)) {
4873
+ if (seen.has(skill.name)) continue;
4874
+ seen.add(skill.name);
4875
+ skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
4876
+ }
4877
+ };
4878
+ const workspaceRoot = path.join(agentDir, "..", "..");
4879
+ add(agentDir, "");
4880
+ add(workspaceRoot, "workspace/");
4881
+ add(workspaceRoot, "workspace/", ".agents/skills");
4882
+ // The account library keeps its relative path: `account/` is expanded by
4883
+ // the file tools only, and a skill's bundled scripts are run from the shell.
4884
+ add(path.join(libraryDir(tenant)), "../../../../library/");
4885
+ return skills;
4886
+ }
4887
+
4888
+ /**
4889
+ * The interpreters a set of scripts needs, read from what they are: the
4890
+ * extension of `run:` (or of inline code), or an explicit `interpreter:`.
4891
+ * Docker's script image is built from the runtime spec, and the base one is
4892
+ * Python only, so a JavaScript tool that declared no `runtime:` (the demo's
4893
+ * inline crm-upload, any folder tool's run.mjs) failed with no node to run
4894
+ * it. Python is named too whenever a .py script is present, because asking
4895
+ * for node alone builds on node:22-slim, which has no python3. Null when
4896
+ * nothing beyond the default is needed.
4897
+ */
4898
+ export function scriptLanguages(scripts: ScriptSpec[]): RuntimeSpec | null {
4899
+ const kind = (sc: ScriptSpec) => {
4900
+ const via = (sc.interpreter ?? "").split(/[\\/]/).pop() ?? "";
4901
+ if (/^node/.test(via)) return "node";
4902
+ if (/^python/.test(via)) return "python";
4903
+ const ext = (sc.run ? path.extname(sc.run) : sc.codeExt ?? "").toLowerCase();
4904
+ if ([".js", ".mjs", ".cjs", ".ts"].includes(ext)) return "node";
4905
+ if (ext === ".py") return "python";
4906
+ return null;
4907
+ };
4908
+ const kinds = new Set(scripts.map(kind));
4909
+ if (!kinds.has("node")) return null;
4910
+ return { node: true, ...(kinds.has("python") ? { python: true } : {}), packages: [], npm: [] };
4911
+ }