@foldrun/core 0.6.0 → 1.0.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 (98) hide show
  1. package/README.md +14 -1
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/agent-import.d.ts +21 -0
  5. package/dist/src/agent-import.js +116 -0
  6. package/dist/src/api-tools.js +14 -2
  7. package/dist/src/approvals.d.ts +8 -1
  8. package/dist/src/approvals.js +10 -5
  9. package/dist/src/completions.js +1 -0
  10. package/dist/src/confine.d.ts +1 -0
  11. package/dist/src/confine.js +33 -4
  12. package/dist/src/deploy.d.ts +9 -0
  13. package/dist/src/deploy.js +31 -3
  14. package/dist/src/evals.js +22 -5
  15. package/dist/src/hooks.d.ts +114 -0
  16. package/dist/src/hooks.js +370 -0
  17. package/dist/src/host-env.d.ts +14 -0
  18. package/dist/src/host-env.js +26 -0
  19. package/dist/src/install-helper.d.ts +17 -0
  20. package/dist/src/install-helper.js +134 -0
  21. package/dist/src/install-tool.d.ts +128 -0
  22. package/dist/src/install-tool.js +133 -0
  23. package/dist/src/kinds.d.ts +1 -1
  24. package/dist/src/kinds.js +26 -0
  25. package/dist/src/library.d.ts +1 -1
  26. package/dist/src/library.js +17 -1
  27. package/dist/src/mail.js +1 -1
  28. package/dist/src/model-credential.d.ts +16 -0
  29. package/dist/src/model-credential.js +40 -0
  30. package/dist/src/model-env.d.ts +17 -0
  31. package/dist/src/model-env.js +57 -0
  32. package/dist/src/notify.d.ts +8 -0
  33. package/dist/src/notify.js +10 -1
  34. package/dist/src/package.d.ts +67 -0
  35. package/dist/src/package.js +594 -0
  36. package/dist/src/platform.d.ts +5 -0
  37. package/dist/src/platform.js +1 -0
  38. package/dist/src/provider-health.js +3 -0
  39. package/dist/src/providers.d.ts +5 -0
  40. package/dist/src/providers.js +21 -1
  41. package/dist/src/run-container.d.ts +10 -0
  42. package/dist/src/run-container.js +72 -14
  43. package/dist/src/runner.d.ts +205 -0
  44. package/dist/src/runner.js +273 -36
  45. package/dist/src/runtime.d.ts +77 -0
  46. package/dist/src/runtime.js +230 -5
  47. package/dist/src/script-tools.d.ts +2 -0
  48. package/dist/src/script-tools.js +3 -1
  49. package/dist/src/secrets.d.ts +5 -0
  50. package/dist/src/secrets.js +37 -6
  51. package/dist/src/starter.d.ts +12 -0
  52. package/dist/src/starter.js +38 -4
  53. package/dist/src/step-exec.d.ts +21 -2
  54. package/dist/src/step-exec.js +165 -7
  55. package/dist/src/storage.d.ts +7 -1
  56. package/dist/src/storage.js +14 -1
  57. package/dist/src/store.d.ts +31 -1
  58. package/dist/src/store.js +90 -8
  59. package/dist/src/tool-names.d.ts +3 -1
  60. package/dist/src/tool-names.js +4 -2
  61. package/dist/src/tool-test.js +8 -1
  62. package/dist/src/zip.d.ts +17 -0
  63. package/dist/src/zip.js +184 -0
  64. package/index.ts +3 -0
  65. package/package.json +18 -1
  66. package/src/agent-import.ts +154 -0
  67. package/src/api-tools.ts +16 -1
  68. package/src/approvals.ts +10 -3
  69. package/src/completions.ts +1 -0
  70. package/src/confine.ts +36 -5
  71. package/src/deploy.ts +28 -3
  72. package/src/evals.ts +21 -3
  73. package/src/hooks.ts +406 -0
  74. package/src/host-env.ts +27 -0
  75. package/src/install-helper.ts +143 -0
  76. package/src/install-tool.ts +154 -0
  77. package/src/kinds.ts +28 -0
  78. package/src/library.ts +18 -2
  79. package/src/mail.ts +1 -1
  80. package/src/model-credential.ts +41 -0
  81. package/src/model-env.ts +66 -0
  82. package/src/notify.ts +11 -1
  83. package/src/package.ts +664 -0
  84. package/src/platform.ts +6 -0
  85. package/src/provider-health.ts +3 -0
  86. package/src/providers.ts +27 -1
  87. package/src/run-container.ts +75 -14
  88. package/src/runner.ts +279 -36
  89. package/src/runtime.ts +235 -5
  90. package/src/script-tools.ts +5 -1
  91. package/src/secrets.ts +34 -6
  92. package/src/starter.ts +44 -4
  93. package/src/step-exec.ts +171 -11
  94. package/src/storage.ts +13 -0
  95. package/src/store.ts +89 -7
  96. package/src/tool-names.ts +4 -2
  97. package/src/tool-test.ts +8 -1
  98. package/src/zip.ts +189 -0
package/src/runner.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  // fail without failing the flow. A direct agent run is a one-step ad-hoc
4
4
  // flow, so there is a single execution path.
5
5
 
6
+ import { hookNames } from "./hooks.ts";
6
7
  import fs from "node:fs";
8
+ import os from "node:os";
7
9
  import path from "node:path";
8
10
  import matter from "gray-matter";
9
11
  import { spawn } from "node:child_process";
@@ -14,8 +16,8 @@ import { resolveAgentPath } from "./confine.ts";
14
16
  import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.ts";
15
17
  import { eventUrl } from "./webhook.ts";
16
18
  import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
17
- import { hostSafeEnv } from "./host-env.ts";
18
- import { resolveModelCredential, credentialLine } from "./model-credential.ts";
19
+ import { hostSafeEnv, platformApiUrl } from "./host-env.ts";
20
+ import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
19
21
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
20
22
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
21
23
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
@@ -88,8 +90,10 @@ import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./trans
88
90
  import { providerPreset } from "./providers.ts";
89
91
  import { buildScriptTools, parseScripts, type ExecutionContext, type ScriptSpec } from "./script-tools.ts";
90
92
  import { libraryDir, libraryTools, libraryMemoryIndex } from "./library.ts";
91
- import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime, type PreparedRuntime, type RuntimeSpec } from "./runtime.ts";
92
- import { materializeFiles, harvestFiles } from "./storage.ts";
93
+ import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime, stepInstallEnv, type PreparedRuntime, type RuntimeSpec } from "./runtime.ts";
94
+ import { buildInstallTool, INSTALL_TOOL } from "./install-tool.ts";
95
+ import { dataRoot } from "./paths.ts";
96
+ import { materializeFiles, harvestFiles, storageBaseline } from "./storage.ts";
93
97
  import { chooseExecutor, ensureImage } from "./container.ts";
94
98
  import { stampBundle } from "./okf.ts";
95
99
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
@@ -707,7 +711,7 @@ export function resolveDocLinks(text: string, workspaceRoot: string): string {
707
711
  });
708
712
  }
709
713
 
710
- function agentContext(
714
+ export function agentContext(
711
715
  agentDir: string,
712
716
  tenant: string,
713
717
  tags: string[] = [],
@@ -1124,8 +1128,11 @@ function agentContext(
1124
1128
  //
1125
1129
  // The URL sits in git; the token is a ${SECRET} resolved server-side. An
1126
1130
  // agent's own block wins over the workspace's, like everything else here.
1127
- const providerBlock =
1131
+ // Then the account's own model key (Settings → Model), when it set one:
1132
+ // an account that brings its key need not write a block anywhere.
1133
+ const fileBlock =
1128
1134
  (front.provider as Record<string, unknown> | undefined) ?? workspaceFrontmatter(agentDir, tenant).provider;
1135
+ const providerBlock = fileBlock ?? accountModelBlock((name) => getSecret(tenant, name)?.value ?? null);
1129
1136
  let providerEnv: Record<string, string> = {};
1130
1137
  let providerLabel: string | null = null;
1131
1138
  // Secret values pulled into the provider block, name → value. Kept apart
@@ -1278,6 +1285,9 @@ function agentContext(
1278
1285
  // one — the `websearch` library tool reads it. Not a secret; rides with
1279
1286
  // the clock so it reaches scripts, the sandbox and verify alike.
1280
1287
  ...(process.env.FOLDRUN_SEARCH_URL ? { FOLDRUN_SEARCH_URL: process.env.FOLDRUN_SEARCH_URL } : {}),
1288
+ // Where this install's own API answers (host-env.ts platformApiUrl), so a
1289
+ // script calls `$FOLDRUN_API_URL/workspaces/…` instead of naming a host.
1290
+ ...(platformApiUrl() ? { FOLDRUN_API_URL: platformApiUrl()! } : {}),
1281
1291
  // Whose index our web search asks when the agent named a search API.
1282
1292
  // Unset means the account's own engine. The name, never the key.
1283
1293
  ...(searchChoice.shape === "direct" && searchChoice.provider
@@ -1332,6 +1342,7 @@ function agentContext(
1332
1342
  ...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
1333
1343
  ...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
1334
1344
  ...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
1345
+ ...(browseSettings.state ? { FOLDRUN_BROWSER_STATE: browseSettings.state } : {}),
1335
1346
  ...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
1336
1347
  ...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
1337
1348
  };
@@ -1365,7 +1376,20 @@ function agentContext(
1365
1376
  );
1366
1377
  if (scriptTools.promptLines.length) {
1367
1378
  parts.push(
1368
- `# Tools from scripts\n\nCall these directly — they run workspace scripts for you:\n\n${scriptTools.promptLines.join("\n")}`,
1379
+ `# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
1380
+ `\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`,
1381
+ );
1382
+ }
1383
+ // The web tool's own description lists every action, so an agent whose
1384
+ // search or fetch a model provider answers called web action=search,
1385
+ // was refused, and only then used WebSearch — eight refusals in one
1386
+ // reddit-desk validator step on 6 Oct 2026. Say it before the first call.
1387
+ if (builtins.length) {
1388
+ const named = builtins.map((b) => b.split("="));
1389
+ parts.push(
1390
+ `# Web search and fetch on this run\n\n` +
1391
+ named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
1392
+ `\n\nThe web tool's other actions work as usual.`,
1369
1393
  );
1370
1394
  }
1371
1395
  if (declared.length) {
@@ -1379,7 +1403,13 @@ function agentContext(
1379
1403
  "Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
1380
1404
  "what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
1381
1405
  "for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
1382
- "directory a tool made outside the workspace, such as a checkout, is reached only through that tool.",
1406
+ "directory a tool made outside the workspace, such as a checkout, is reached only through that tool. " +
1407
+ // 45 refusals in 34 runs (6–7 Oct 2026), nearly all an agent rewriting
1408
+ // a plan or draft an earlier run left: the Write tool refuses a file
1409
+ // that exists and has not been Read in this step, before any hook of
1410
+ // ours can see the call. Said once here instead of learned per step.
1411
+ "To replace a file that already exists, Read it first — Write refuses a file you have not Read in this step (a new file needs no Read). " +
1412
+ "Built-in tools are capitalised: Read, Write, Edit, Glob, Grep, Bash.",
1383
1413
  );
1384
1414
 
1385
1415
  // The run's one-line summary is the first meaningful line of the last step
@@ -1449,6 +1479,12 @@ function agentContext(
1449
1479
  // names it (FOLDRUN_WEB_BUILTIN) rather than asking our engine.
1450
1480
  if (toolName === "web" && !ref.linked) {
1451
1481
  allowed.push(...providerWebTools);
1482
+ // web writes what it found to files and says "Read that file" — a
1483
+ // screenshot, a crawl, a page saved whole. An agent granted web alone
1484
+ // was told to Read and refused it (blog-desk claim-checker, 7 Oct
1485
+ // 2026: "Read is disabled for this session"). Reading is confined to
1486
+ // the workspace like every file tool; disallowedTools still removes it.
1487
+ if (!allowed.includes("Read")) allowed.push("Read");
1452
1488
  continue;
1453
1489
  }
1454
1490
 
@@ -1470,6 +1506,8 @@ function agentContext(
1470
1506
  else if (toolName === "ask") wantAsk = true;
1471
1507
  else wantDesks = true;
1472
1508
  if (available[toolName]) shadowed.push(toolName);
1509
+ } else if (toolName === "install") {
1510
+ // Granted to every agent anyway (below); naming it is harmless.
1473
1511
  } else if (BUILTIN_TOOLS.has(toolName)) {
1474
1512
  allowed.push(toolName);
1475
1513
  if (available[toolName]) shadowed.push(toolName);
@@ -1531,6 +1569,11 @@ function agentContext(
1531
1569
  // what it exposes, which is the unit a person actually reasons about anyway.
1532
1570
  for (const name of mcpNames) allowed.push(`mcp__${name}`, `mcp__${name}__*`);
1533
1571
 
1572
+ // install — every agent may install what it finds it needs mid-step (apt,
1573
+ // pip, npm), declared or not (install-tool.ts). Granted by default; taken
1574
+ // away by disallowedTools: [install], and absent in plan mode (read-only).
1575
+ allowed.push(INSTALL_TOOL);
1576
+
1534
1577
  // Claude Code's spellings, so an agent written for it mostly runs here.
1535
1578
  // `permissionMode: plan` is its read-only mode; ours is the `read` group.
1536
1579
  if (front.permissionMode === "plan") {
@@ -1538,6 +1581,7 @@ function agentContext(
1538
1581
  allowed.push(...TOOL_MAP.read, ...apiTools.toolNames, ...scriptTools.toolNames);
1539
1582
  }
1540
1583
  const denied: string[] = refNames(front.disallowedTools);
1584
+ if (denied.includes("install")) denied.push(INSTALL_TOOL);
1541
1585
  const finalAllowed = allowed.filter((t) => !denied.includes(t));
1542
1586
 
1543
1587
  return {
@@ -1756,6 +1800,42 @@ async function terminalAsk(question: string, options?: string[]): Promise<string
1756
1800
  }
1757
1801
  }
1758
1802
 
1803
+ /**
1804
+ * What the person at the gate said, framed for the step's prompt — or "".
1805
+ *
1806
+ * The note is the freshest instruction the step has — later than the flow
1807
+ * file, aimed at this exact run. When the gate asked a question (ask:), the
1808
+ * note IS the answer and is framed as one.
1809
+ *
1810
+ * Both halves go through resolveDocLinks for the same reason the
1811
+ * instruction does: this is authored text on its way to becoming a prompt,
1812
+ * and the rule is that ALL of it expands. A person answering "which list?"
1813
+ * with [[dead-ends]] was sending the model two literal brackets — the one
1814
+ * reference syntax the workspace teaches, and the one place it silently did
1815
+ * nothing. The previous step's results are NOT resolved, deliberately: that
1816
+ * is model output, and a model that happens to emit [[brackets]] must not
1817
+ * thereby name a file.
1818
+ *
1819
+ * An ask: gate approved with no note still says so. It used to add nothing,
1820
+ * so the step never learned its question had been answered: gbp-desk's
1821
+ * rivals sender, told "a plain approval means all offices", saw no answer
1822
+ * at all and stopped BLOCKED (run-muu6r18y-417e, 2026-10-05).
1823
+ */
1824
+ export function operatorBlock(step: Pick<StepRecord, "ask" | "approvalNote" | "approvedAt">, workspaceRoot: string): string {
1825
+ const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
1826
+ if (step.approvalNote) {
1827
+ const note = resolveDocLinks(step.approvalNote, workspaceRoot);
1828
+ return step.ask
1829
+ ? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
1830
+ : `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
1831
+ }
1832
+ if (step.ask && step.approvedAt) {
1833
+ return `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer: they approved it with no note — a plain yes.\n</operator_answer>`;
1834
+ }
1835
+ return "";
1836
+ }
1837
+
1838
+
1759
1839
  async function runStep(
1760
1840
  agentDir: string,
1761
1841
  tenant: string,
@@ -1807,6 +1887,7 @@ async function runStep(
1807
1887
  // cover a script's own stdout.
1808
1888
  let redactions: [string, string][] = [];
1809
1889
  let fileDir: string | null = null;
1890
+ let installDir: string | null = null;
1810
1891
  let disposeRuntime: (() => void) | undefined;
1811
1892
  // Can this step's tools act outside the workspace? Set once its context is
1812
1893
  // read; the verdict at the end of this function reads it.
@@ -1963,6 +2044,29 @@ async function runStep(
1963
2044
  const line = credentialLine(credential);
1964
2045
  push(line.type, line.text);
1965
2046
  }
2047
+ // A hosted platform runs only its operator's accounts on its own model
2048
+ // credential; every other account brings its own API key. Refused here,
2049
+ // before a sandbox is rented, with what to do about it — not run on the
2050
+ // operator's key, and not failed later by a provider.
2051
+ if (!platform.platformKeyAllowed(tenant)) {
2052
+ const own = Object.values(providerSecrets).concat(Object.values(providerEnv));
2053
+ if (!providerLabel) {
2054
+ push(
2055
+ "error",
2056
+ `no model key: this account runs on its own API key, and none is set. Add one in Settings → Model ` +
2057
+ `(or \`foldrun model set <provider> --key …\`), or write a provider: block in AGENTS.md`,
2058
+ );
2059
+ step.status = "failed";
2060
+ save();
2061
+ return;
2062
+ }
2063
+ if (own.some((v) => isClaudeLoginToken(v))) {
2064
+ push("error", "model key: a Claude login token (sk-ant-oat…) cannot be used here — use an API key from your provider");
2065
+ step.status = "failed";
2066
+ save();
2067
+ return;
2068
+ }
2069
+ }
1966
2070
  for (const w of providerWarnings) push("error", w);
1967
2071
  // A level that wrote a language nobody can read was skipped, not obeyed.
1968
2072
  for (const l of language.lines) push("info", l);
@@ -2143,28 +2247,7 @@ async function runStep(
2143
2247
  // happening to contain brackets.
2144
2248
  prompt += `\n\n<event>\nThis step waited for an external event. What arrived:\n${step.eventPayload}\n</event>`;
2145
2249
  }
2146
- if (step.approvalNote) {
2147
- // The person at the gate said something while letting the run through.
2148
- // That is the freshest instruction the step has — later than the flow
2149
- // file, aimed at this exact run. When the gate asked a question
2150
- // (ask:), the note IS the answer and is framed as one.
2151
- //
2152
- // Both halves go through resolveDocLinks for the same reason the
2153
- // instruction above does: this is authored text on its way to becoming
2154
- // a prompt, and the rule is that ALL of it expands. A person answering
2155
- // "which list?" with [[dead-ends]] was sending the model two literal
2156
- // brackets — the one reference syntax the workspace teaches, and the
2157
- // one place it silently did nothing.
2158
- //
2159
- // What is NOT resolved, deliberately: the previous step's results
2160
- // above. That is model output, not something a person wrote, and a
2161
- // model that happens to emit [[brackets]] must not thereby name a file.
2162
- const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
2163
- const note = resolveDocLinks(step.approvalNote, workspaceRoot);
2164
- prompt += step.ask
2165
- ? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
2166
- : `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
2167
- }
2250
+ prompt += operatorBlock(step, workspaceRoot);
2168
2251
  if (step.delegate?.length) {
2169
2252
  // The chooser's contract. The allowed set comes from the flow file;
2170
2253
  // the runner parses exactly this shape back out of the result, so the
@@ -2210,6 +2293,9 @@ async function runStep(
2210
2293
  // watched on both paths, because a refusal reads the same from a pod
2211
2294
  // and from this process.
2212
2295
  let lastRefusal = "";
2296
+ // The step's model connection was cut (isModelConnectionLost): the
2297
+ // platform's side going away under the step, not the step failing.
2298
+ let lastDrop = "";
2213
2299
  // And WHY it refused, in words, when anything on the way saw the
2214
2300
  // headers that say so: the translator here, the egress proxy over
2215
2301
  // there. Kept apart from `lastRefusal` — that one decides whether to
@@ -2217,6 +2303,7 @@ async function runStep(
2217
2303
  let refusalWhy = "";
2218
2304
  const pushWatching: typeof push = (type, text, extra) => {
2219
2305
  if (type === "error" && isProviderRefusal(text)) lastRefusal = text;
2306
+ if (type === "error" && isModelConnectionLost(text)) lastDrop = text;
2220
2307
  const why = refusalFromLine(text);
2221
2308
  if (why) refusalWhy = why;
2222
2309
  push(type, text, extra);
@@ -2270,6 +2357,17 @@ async function runStep(
2270
2357
  const limitOwners = toolOwners(apiTools.owners, scriptSpecs);
2271
2358
  const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
2272
2359
  if (limitOpts.limits) push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
2360
+ // `hooks:` — the account's, the workspace's, then the agent's own, each
2361
+ // once; resolved and run inside the step (hooks.ts, step-exec.ts).
2362
+ const stepHookNames = [...new Set([
2363
+ ...hookNames(readAgentsMd(accountDir(tenant))?.data?.hooks),
2364
+ ...hookNames(readAgentsMd(workspaceRootOf(agentDir))?.data?.hooks),
2365
+ ...hookNames(front.hooks),
2366
+ ])];
2367
+ const hookOpts = stepHookNames.length
2368
+ ? { hookNames: stepHookNames, hookBase: { run_id: runId ?? null, step: stepIndex ?? null, agent: step.agent, workspace: path.basename(workspaceRootOf(agentDir)) } }
2369
+ : {};
2370
+ if (stepHookNames.length) push("info", `hooks: ${stepHookNames.join(", ")}`);
2273
2371
 
2274
2372
  const isolation = process.env.FOLDRUN_RUN_ISOLATION;
2275
2373
  if (isolatedRun()) {
@@ -2436,6 +2534,7 @@ async function runStep(
2436
2534
  schema: resolvedSchema ?? undefined,
2437
2535
  maxTurns: step.maxTurns,
2438
2536
  ...limitOpts,
2537
+ ...hookOpts,
2439
2538
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
2440
2539
  // Outward steps keep a browser of their own: an executor that
2441
2540
  // could browse from the slim image through the account's pod
@@ -2511,6 +2610,7 @@ async function runStep(
2511
2610
  lastArgs = a;
2512
2611
  return runIsolated(a);
2513
2612
  };
2613
+ let attemptFrom = step.events.length;
2514
2614
  let outcome = await runTracked(firstArgs);
2515
2615
  step.sandbox = null; // whatever happens next starts its own
2516
2616
  // A 401 from a token that rotated mid-step is not a broken key: the
@@ -2548,6 +2648,48 @@ async function runStep(
2548
2648
  outcome = await runTracked(again);
2549
2649
  outcome = withEarlierTiming(outcome, previous);
2550
2650
  }
2651
+ // The model connection was cut under the step — the egress proxy or the
2652
+ // worker went away (6 Oct 2026: a worker restarted every 4.5 minutes and
2653
+ // every desk running failed on "Connection refused"). That is the
2654
+ // platform's fault, so the step runs again, whatever its `retry:` —
2655
+ // unless it can act outside the workspace and its tools already ran:
2656
+ // the post may be up, and a second attempt would post it again.
2657
+ for (let n = 1; n <= DROP_RETRIES && outcome.status === "failed" && lastDrop; n++) {
2658
+ if (!dropRetrySafe(canActOutward, step.events.slice(attemptFrom))) {
2659
+ push("info", `the model connection dropped after this step's tools ran — not retried: they can act outside the workspace, and another attempt could repeat what they did`);
2660
+ break;
2661
+ }
2662
+ const wait = Math.round(retryBaseMs() * 2 * n);
2663
+ push("info", `the model connection dropped (${lastDrop.slice(0, 80)}) — the platform's side, not the step's; running the step again in ${Math.round(wait / 1000)}s (${n} of ${DROP_RETRIES})`);
2664
+ await sleep(wait);
2665
+ lastDrop = "";
2666
+ lastRefusal = "";
2667
+ attemptFrom = step.events.length;
2668
+ const previous = outcome.timing;
2669
+ const again = isolatedArgs(platformModelEnv());
2670
+ await lease?.commit();
2671
+ outcome = withEarlierTiming(await runTracked(again), previous);
2672
+ }
2673
+ // The model wrote its tool call out as text — `<invoke name="…">` in
2674
+ // its last message — and stopped. Nothing ran, so nothing was done:
2675
+ // seo-digest's editor three times (27 Sep, 4 and 5 Oct 2026), its own
2676
+ // retry included, because the retry asked the same way. Run it once
2677
+ // more with that said plainly, on the same safety rule as a dropped
2678
+ // connection: never again after an outward tool already ran.
2679
+ for (let n = 1; n <= TEXT_CALL_RETRIES && wroteToolCallAsText(attemptText(outcome.result, step.events.slice(attemptFrom))); n++) {
2680
+ if (!dropRetrySafe(canActOutward, step.events.slice(attemptFrom))) {
2681
+ push("info", "the model wrote a tool call as text instead of making it — not run again: this step's tools can act outside the workspace and already ran");
2682
+ break;
2683
+ }
2684
+ push("info", `the model wrote a tool call as text instead of making it, so nothing ran — running the step again with a note (${n} of ${TEXT_CALL_RETRIES})`);
2685
+ attemptFrom = step.events.length;
2686
+ lastRefusal = "";
2687
+ const previous = outcome.timing;
2688
+ const base = isolatedArgs(platformModelEnv());
2689
+ const again = { ...base, input: { ...base.input, prompt: `${base.input.prompt}\n\n${TEXT_CALL_NOTE}` } };
2690
+ await lease?.commit();
2691
+ outcome = withEarlierTiming(await runTracked(again), previous);
2692
+ }
2551
2693
  // The second supply, tried exactly once, and only when the primary
2552
2694
  // refused over money/auth/limits rather than the work failing. The
2553
2695
  // retry is a fresh sandbox — the failed one is gone, and driveRun's
@@ -2706,6 +2848,20 @@ async function runStep(
2706
2848
  // finally — once, however many attempts follow.
2707
2849
  const mat = materializeFileSecrets(agentDir, stepSecrets);
2708
2850
  fileDir = mat.dir;
2851
+ // install: pip and npm land in a directory of this step's own, gone in
2852
+ // the finally; apt has no helper on a bare host and says so.
2853
+ installDir = allowed.includes(INSTALL_TOOL) ? fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-step-")) : null;
2854
+ const installEnv = installDir ? stepInstallEnv(installDir, { ...process.env, ...mat.env }) : {};
2855
+ const installTools = installDir
2856
+ ? buildInstallTool({
2857
+ dir: installDir,
2858
+ socket: null,
2859
+ cacheRoot: path.join(dataRoot(), tenant, ".runtimes"),
2860
+ python: mat.env.VIRTUAL_ENV ? path.join(mat.env.VIRTUAL_ENV, "bin", "python") : "python3",
2861
+ env: { ...hostSafeEnv(), ...mat.env, ...installEnv },
2862
+ emit: (type, text) => push(type, text),
2863
+ })
2864
+ : null;
2709
2865
  const attemptInProcess = (modelEnv: Record<string, string | undefined>) => executeStep({
2710
2866
  agentDir,
2711
2867
  workspaceRoot,
@@ -2725,6 +2881,7 @@ async function runStep(
2725
2881
  ...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
2726
2882
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
2727
2883
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
2884
+ ...(installTools ? { foldrun_install: installTools.server } : {}),
2728
2885
  ...mcpServers,
2729
2886
  },
2730
2887
  // Declared secrets reach the agent's scripts as env vars; the model
@@ -2732,7 +2889,7 @@ async function runStep(
2732
2889
  // the allowlisted host environment, not process.env: the SDK's
2733
2890
  // Bash tool runs with this, and on a platform process.env holds
2734
2891
  // every tenant's root key and the datastore credentials.
2735
- env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...modelEnv },
2892
+ env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...installEnv, ...modelEnv },
2736
2893
  timeoutSec: step.timeout,
2737
2894
  budgetUsd: stepBudgetUsd,
2738
2895
  budgetNote: stepBudgetNote,
@@ -2742,6 +2899,7 @@ async function runStep(
2742
2899
  schema: resolvedSchema ?? undefined,
2743
2900
  maxTurns: step.maxTurns,
2744
2901
  ...limitOpts,
2902
+ ...hookOpts,
2745
2903
  stopRequested,
2746
2904
  // What the scripts saw, the verify sees: a flow can then check that
2747
2905
  // the proof a step left names THIS run, not one that came before —
@@ -2840,6 +2998,7 @@ async function runStep(
2840
2998
  } finally {
2841
2999
  try {
2842
3000
  cleanupFileSecrets(fileDir);
3001
+ if (installDir) fs.rmSync(installDir, { recursive: true, force: true });
2843
3002
  } catch {
2844
3003
  // best-effort — a leftover scratch file is swept with the run's outputs
2845
3004
  }
@@ -2996,8 +3155,12 @@ function platformFallbackEnv(): Record<string, string> | null {
2996
3155
  const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
2997
3156
  const CREDENTIAL_POLL_MS = 2_000;
2998
3157
 
2999
- function isAuthRefusal(text: string): boolean {
3000
- return /API Error: 401|access token has (been revoked|expired)/i.test(text);
3158
+ /** A refusal of the credential itself, in any of the wordings Claude Code
3159
+ * has used. "Failed to authenticate: OAuth token revoked" (9 Oct 2026, a
3160
+ * step started one second before the 09:07:52Z refresh) matched none of the
3161
+ * older two, so the step failed instead of waiting for the new token. */
3162
+ export function isAuthRefusal(text: string): boolean {
3163
+ return /API Error: 401|access token has (been revoked|expired)|OAuth token (has )?(been )?(revoked|expired)|Failed to authenticate/i.test(text);
3001
3164
  }
3002
3165
 
3003
3166
  /**
@@ -3048,6 +3211,49 @@ function isProviderRefusal(text: string): boolean {
3048
3211
  );
3049
3212
  }
3050
3213
 
3214
+ /** A step's own model connection cut: the egress proxy or the worker it
3215
+ * runs through went away. Only the run's final error, never a tool's — a
3216
+ * WebFetch that could not connect is the step's business, not ours. */
3217
+ export function isModelConnectionLost(text: string): boolean {
3218
+ return /^Claude Code returned an error result: API Error: (Connection (refused|dropped|error)|.*\b(ECONNREFUSED|ECONNRESET|socket hang up)\b)/i.test(text);
3219
+ }
3220
+
3221
+ /** A tool call written out as text rather than made: the model's last
3222
+ * message holds `<invoke name=…>` / `<function_calls>` markup. Only the tail
3223
+ * counts — a reply that quotes such markup mid-way and goes on is fine. */
3224
+ export function wroteToolCallAsText(text: string): boolean {
3225
+ const tail = text.slice(-1500);
3226
+ const open = tail.search(/<(?:antml:)?(?:invoke|function_calls)\b[^>]*>/);
3227
+ if (open < 0) return false;
3228
+ // Quoted mid-reply and then the reply carries on: not a stalled call.
3229
+ const after = tail.slice(open).replace(/<\/(?:antml:)?(?:invoke|function_calls)>/g, "\u0000");
3230
+ const lastClose = after.lastIndexOf("\u0000");
3231
+ return lastClose < 0 || after.slice(lastClose + 1).trim().length < 200;
3232
+ }
3233
+
3234
+ /** What an attempt said last: its result, or — when it ended without one —
3235
+ * the error that carried the model's final text. */
3236
+ function attemptText(result: string | null | undefined, events: { type: string; text?: string }[]): string {
3237
+ if (result && result.trim()) return result;
3238
+ const lastError = [...events].reverse().find((e) => e.type === "error");
3239
+ return lastError?.text ?? "";
3240
+ }
3241
+
3242
+ /** Runs again after a tool call written as text. */
3243
+ export const TEXT_CALL_RETRIES = 1;
3244
+ export const TEXT_CALL_NOTE =
3245
+ "Note from the platform: your last attempt ended by writing a tool call out as text (an <invoke …> block), so it was not run and nothing happened. " +
3246
+ "Make every tool call as a real tool call — never type its markup into your message.";
3247
+
3248
+ /** How many times a step whose model connection was cut runs again. */
3249
+ export const DROP_RETRIES = 2;
3250
+
3251
+ /** May a step whose connection dropped run again? Yes, unless its tools can
3252
+ * act outward and one ran in this attempt (actedThenFailedCheck's rule). */
3253
+ export function dropRetrySafe(canActOutward: boolean, attemptEvents: { type: string }[]): boolean {
3254
+ return !(canActOutward && attemptEvents.some((e) => e.type === "tool"));
3255
+ }
3256
+
3051
3257
  /** How many times a busy provider is given another moment, and the longest
3052
3258
  * any one wait may be. Three is the industry's habit and the point where
3053
3259
  * waiting longer stops being kinder than failing. */
@@ -3637,6 +3843,32 @@ function driveRunInner(
3637
3843
  //
3638
3844
  // Runs on resume too — a parked run's container is long gone, and the
3639
3845
  // one it comes back in needs the same files the first one had.
3846
+ //
3847
+ // A run re-driven after its worker died is the exception to "the store
3848
+ // is newer than the disk": a park harvests before it parks, a crash does
3849
+ // not, so what this run's finished steps wrote to storage/ is only on
3850
+ // disk, and materializing first wrote the store's older copy over it.
3851
+ // On 6 Oct 2026 a worker restart put reddit-desk's 2 Oct post back over
3852
+ // that morning's draft, and a rerun from the validator would have
3853
+ // posted it a second time. So a run that has been here before (its
3854
+ // baseline is on disk) stores what it changed first; files it did not
3855
+ // touch still come fresh from the store.
3856
+ const baselineFile = path.join(runRoot, "runs", run.id, "storage-baseline.json");
3857
+ if (fs.existsSync(baselineFile)) {
3858
+ try {
3859
+ const changedFrom = JSON.parse(fs.readFileSync(baselineFile, "utf8")) as Record<string, string>;
3860
+ const { saved } = await harvestFiles(tenant, workspace, `run:${run.id}`, { changedFrom });
3861
+ if (saved.length) {
3862
+ run.steps[0]?.events.push({
3863
+ t: new Date().toISOString(),
3864
+ type: "info",
3865
+ text: `files: kept ${saved.join(", ")} written before the run was picked up again`,
3866
+ });
3867
+ }
3868
+ } catch {
3869
+ // the materialize below still runs; at worst this is the old behaviour
3870
+ }
3871
+ }
3640
3872
  try {
3641
3873
  const brought = await materializeFiles(tenant, workspace);
3642
3874
  if (brought.length) {
@@ -3646,6 +3878,8 @@ function driveRunInner(
3646
3878
  text: `files: ${brought.length} brought into the workspace`,
3647
3879
  });
3648
3880
  }
3881
+ fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
3882
+ fs.writeFileSync(baselineFile, JSON.stringify(storageBaseline(tenant, workspace)));
3649
3883
  } catch (err) {
3650
3884
  // A file store that is down must not take the run with it: most flows
3651
3885
  // never touch storage/, and the ones that do will fail their own verify.
@@ -4111,7 +4345,7 @@ function driveRunInner(
4111
4345
  save();
4112
4346
  if (freshGroup.every((s) => s.status !== "pending")) {
4113
4347
  // everything in this group was rejected
4114
- if (freshGroup.some((s) => s.status === "failed" && !s.optional)) {
4348
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
4115
4349
  run.status = "failed";
4116
4350
  break;
4117
4351
  }
@@ -4353,8 +4587,13 @@ function driveRunInner(
4353
4587
  effort: step.effort,
4354
4588
  timeout: step.timeout,
4355
4589
  // The rescuer inherits the contract, not just the job: a step
4356
- // that promised data still owes data when someone else does it.
4590
+ // that promised data still owes data when someone else does it,
4591
+ // and a step that had to pass a check still has to pass it — a
4592
+ // rescue whose result stood unchecked was a way round the very
4593
+ // check that failed (found 7 Oct 2026, adding rescuers to
4594
+ // medium-desk).
4357
4595
  output: step.output,
4596
+ ...(step.verify ? { verify: step.verify } : {}),
4358
4597
  attempts: 0,
4359
4598
  status: "pending",
4360
4599
  events: [],
@@ -4389,7 +4628,11 @@ function driveRunInner(
4389
4628
  save();
4390
4629
  }
4391
4630
 
4392
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional);
4631
+ // A step carried into a re-run failed in the run it came from, not
4632
+ // here: the person chose to start after it. Counting it stopped
4633
+ // fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
4634
+ // a second, before the one step it was asked to run.
4635
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
4393
4636
  if (requiredFailed) {
4394
4637
  run.status = "failed";
4395
4638
  break;