@foldrun/core 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/account.d.ts +45 -0
  4. package/dist/src/account.js +130 -0
  5. package/dist/src/api-tools.d.ts +8 -0
  6. package/dist/src/api-tools.js +22 -4
  7. package/dist/src/browser-pod.d.ts +129 -0
  8. package/dist/src/browser-pod.js +284 -0
  9. package/dist/src/catalog.js +2 -1
  10. package/dist/src/clock.d.ts +49 -0
  11. package/dist/src/clock.js +121 -0
  12. package/dist/src/completions.js +17 -8
  13. package/dist/src/confine.d.ts +22 -0
  14. package/dist/src/confine.js +218 -3
  15. package/dist/src/deploy.d.ts +33 -0
  16. package/dist/src/deploy.js +145 -6
  17. package/dist/src/egress.d.ts +7 -0
  18. package/dist/src/egress.js +6 -0
  19. package/dist/src/eol.d.ts +2 -0
  20. package/dist/src/eol.js +7 -0
  21. package/dist/src/evals.d.ts +5 -0
  22. package/dist/src/evals.js +6 -2
  23. package/dist/src/flow-lint.d.ts +8 -0
  24. package/dist/src/flow-lint.js +223 -1
  25. package/dist/src/flow-patterns.d.ts +141 -0
  26. package/dist/src/flow-patterns.js +568 -0
  27. package/dist/src/gitrepo.d.ts +18 -0
  28. package/dist/src/gitrepo.js +49 -0
  29. package/dist/src/kinds.js +1 -1
  30. package/dist/src/language.d.ts +17 -0
  31. package/dist/src/language.js +55 -0
  32. package/dist/src/layout.d.ts +69 -0
  33. package/dist/src/layout.js +200 -0
  34. package/dist/src/library.d.ts +9 -0
  35. package/dist/src/library.js +30 -3
  36. package/dist/src/limits.d.ts +84 -0
  37. package/dist/src/limits.js +287 -0
  38. package/dist/src/locale.d.ts +47 -0
  39. package/dist/src/locale.js +177 -0
  40. package/dist/src/mail.d.ts +88 -0
  41. package/dist/src/mail.js +245 -0
  42. package/dist/src/model-probe.d.ts +3 -0
  43. package/dist/src/model-probe.js +1 -0
  44. package/dist/src/notify.d.ts +53 -7
  45. package/dist/src/notify.js +194 -126
  46. package/dist/src/openapi.js +5 -3
  47. package/dist/src/operator.d.ts +201 -0
  48. package/dist/src/operator.js +251 -0
  49. package/dist/src/paths.d.ts +28 -0
  50. package/dist/src/paths.js +55 -0
  51. package/dist/src/platform.d.ts +43 -3
  52. package/dist/src/platform.js +9 -1
  53. package/dist/src/provider-health.js +3 -2
  54. package/dist/src/providers.d.ts +481 -0
  55. package/dist/src/providers.js +1105 -6
  56. package/dist/src/refusal.d.ts +83 -0
  57. package/dist/src/refusal.js +281 -0
  58. package/dist/src/run-container.d.ts +149 -13
  59. package/dist/src/run-container.js +403 -42
  60. package/dist/src/runner.d.ts +142 -9
  61. package/dist/src/runner.js +1027 -122
  62. package/dist/src/runtime.d.ts +52 -0
  63. package/dist/src/runtime.js +493 -58
  64. package/dist/src/script-tools.d.ts +3 -2
  65. package/dist/src/script-tools.js +35 -3
  66. package/dist/src/secret-files.d.ts +6 -5
  67. package/dist/src/secret-files.js +24 -11
  68. package/dist/src/secret-health.d.ts +30 -6
  69. package/dist/src/secret-health.js +42 -20
  70. package/dist/src/secrets.d.ts +19 -0
  71. package/dist/src/secrets.js +155 -86
  72. package/dist/src/starter.d.ts +9 -0
  73. package/dist/src/starter.js +92 -11
  74. package/dist/src/step-exec.d.ts +61 -2
  75. package/dist/src/step-exec.js +414 -36
  76. package/dist/src/step-removal.d.ts +15 -0
  77. package/dist/src/step-removal.js +49 -0
  78. package/dist/src/storage.d.ts +23 -4
  79. package/dist/src/storage.js +125 -23
  80. package/dist/src/store.d.ts +218 -37
  81. package/dist/src/store.js +347 -60
  82. package/dist/src/subagents.d.ts +75 -0
  83. package/dist/src/subagents.js +149 -0
  84. package/dist/src/tool-names.d.ts +13 -1
  85. package/dist/src/tool-names.js +38 -11
  86. package/dist/src/tool-programs.d.ts +26 -0
  87. package/dist/src/tool-programs.js +172 -0
  88. package/dist/src/tool-test.js +104 -12
  89. package/dist/src/translator.d.ts +8 -2
  90. package/dist/src/translator.js +69 -23
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/triggers.d.ts +3 -0
  93. package/dist/src/triggers.js +10 -2
  94. package/dist/src/untrusted-fetch.d.ts +11 -0
  95. package/dist/src/untrusted-fetch.js +77 -0
  96. package/dist/src/web-actions.d.ts +91 -0
  97. package/dist/src/web-actions.js +199 -0
  98. package/dist/src/webhook.d.ts +4 -0
  99. package/dist/src/webhook.js +39 -0
  100. package/index.ts +3 -0
  101. package/package.json +3 -3
  102. package/src/account.ts +149 -0
  103. package/src/api-tools.ts +32 -4
  104. package/src/browser-pod.ts +313 -0
  105. package/src/catalog.ts +2 -1
  106. package/src/clock.ts +149 -0
  107. package/src/completions.ts +17 -8
  108. package/src/confine.ts +218 -3
  109. package/src/deploy.ts +137 -6
  110. package/src/egress.ts +13 -1
  111. package/src/eol.ts +7 -0
  112. package/src/evals.ts +11 -2
  113. package/src/flow-lint.ts +253 -1
  114. package/src/flow-patterns.ts +597 -0
  115. package/src/gitrepo.ts +48 -0
  116. package/src/kinds.ts +1 -1
  117. package/src/language.ts +62 -0
  118. package/src/layout.ts +228 -0
  119. package/src/library.ts +37 -3
  120. package/src/limits.ts +290 -0
  121. package/src/locale.ts +217 -0
  122. package/src/mail.ts +316 -0
  123. package/src/model-probe.ts +4 -0
  124. package/src/notify.ts +189 -103
  125. package/src/openapi.ts +5 -3
  126. package/src/operator.ts +300 -0
  127. package/src/paths.ts +54 -0
  128. package/src/platform.ts +52 -3
  129. package/src/provider-health.ts +3 -2
  130. package/src/providers.ts +1368 -6
  131. package/src/refusal.ts +320 -0
  132. package/src/run-container.ts +439 -43
  133. package/src/runner.ts +1050 -122
  134. package/src/runtime.ts +487 -55
  135. package/src/script-tools.ts +38 -5
  136. package/src/secret-files.ts +22 -10
  137. package/src/secret-health.ts +64 -19
  138. package/src/secrets.ts +149 -87
  139. package/src/starter.ts +93 -11
  140. package/src/step-exec.ts +429 -37
  141. package/src/step-removal.ts +59 -0
  142. package/src/storage.ts +141 -22
  143. package/src/store.ts +500 -68
  144. package/src/subagents.ts +189 -0
  145. package/src/tool-names.ts +40 -11
  146. package/src/tool-programs.ts +179 -0
  147. package/src/tool-test.ts +110 -12
  148. package/src/translator.ts +72 -22
  149. package/src/trigger-log.ts +1 -0
  150. package/src/triggers.ts +10 -2
  151. package/src/untrusted-fetch.ts +88 -0
  152. package/src/web-actions.ts +237 -0
  153. package/src/webhook.ts +41 -0
  154. package/templates/hello/agents/curator/agent.md +1 -1
  155. package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/store.ts CHANGED
@@ -20,12 +20,15 @@
20
20
  //
21
21
  // An account is a named directory (per OKF: tenancy is a hosting concern).
22
22
 
23
+ import type { BrowserPodOutcome } from "./browser-pod.ts";
23
24
  import fs from "node:fs";
24
25
  import path from "node:path";
25
26
  import { recordRevision, registerTreeReader, type RevisionFile } from "./history.ts";
26
- import { dataRoot, singleWorkspace } from "./paths.ts";
27
+ import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.ts";
27
28
  import { platform } from "./platform.ts";
29
+ import { toLF, asRaw } from "./eol.ts";
28
30
  import type { TestEffect } from "./test-mode.ts";
31
+ import type { OperatorEvent } from "./operator.ts";
29
32
  import matter from "gray-matter";
30
33
 
31
34
  /**
@@ -40,16 +43,22 @@ import matter from "gray-matter";
40
43
  export function readFrontmatter(file: string): Record<string, unknown> {
41
44
  return matter(fs.readFileSync(file, "utf8")).data as Record<string, unknown>;
42
45
  }
43
- import { ownToolNames, legacyUseNames } from "./tool-names.ts";
46
+ import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.ts";
44
47
  import { refNames } from "./refs.ts";
45
48
  import { parseBudget, budgetProblem } from "./budget.ts";
49
+ import { timezoneProblem } from "./clock.ts";
50
+ import { parseStepLimits, readLimits } from "./limits.ts";
51
+ import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
52
+ import { actionProblems } from "./web-actions.ts";
53
+ import { languageProblem } from "./language.ts";
54
+ import { localeProblems } from "./locale.ts";
46
55
  import {
47
56
  readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles,
48
57
  } from "./okf.ts";
49
58
  import { readTransport, KINDS } from "./kinds.ts";
50
59
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
51
60
  import { starterFiles, accountFiles } from "./starter.ts";
52
- import { trimSlashes } from "./paths.ts";
61
+ import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.ts";
53
62
 
54
63
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
55
64
  // against one folder, which is what `foldrun run ./my-desk` has to mean.
@@ -184,13 +193,14 @@ export function workspaceDir(tenant: string, workspace: string) {
184
193
  * The account root — where `library/`, account secrets and the account-wide
185
194
  * AGENTS.md live.
186
195
  *
187
- * On a laptop there is no account, so it is the workspace's parent: `my-desk/`
188
- * sits beside `library/` and an `AGENTS.md` covering everything there. Same
189
- * rule libraryDir uses, so the two never disagree about where "up" is.
196
+ * On a laptop it is wherever `singleAccountRoot` says: the workspace's parent
197
+ * for a flat `my-desk/`, and the account root two levels up for a workspace
198
+ * inside `my-account/workspaces/`. Same rule libraryDir uses, so the two never
199
+ * disagree about where "up" is.
190
200
  */
191
201
  export function accountDir(tenant: string) {
192
202
  const single = singleWorkspace();
193
- if (single) return path.resolve(single, "..");
203
+ if (single) return singleAccountRoot()!;
194
204
  assertSafeName(tenant, "tenant");
195
205
  return path.join(dataRoot(), tenant);
196
206
  }
@@ -303,15 +313,38 @@ export function assertCanonicalCase(rel: string) {
303
313
  * next deploy or silently handed to every container — both invisible until
304
314
  * someone is bitten.
305
315
  */
306
- export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json"];
316
+ // trigger-log.jsonl: the scheduler's record of why a flow did or did not
317
+ // fire (trigger-log.ts). Before it was listed here every deploy erased it.
318
+ export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
307
319
 
308
320
  /** True for anything the platform, not the author or the agent, writes. */
309
321
  export function isPlatformPath(rel: string): boolean {
310
322
  const norm = rel.replaceAll("\\", "/");
311
323
  if (PLATFORM_FILES.includes(norm)) return true;
324
+ // The vault's lock and in-flight temp files (secrets.ts#mutateVaultFile)
325
+ // are the vault, as far as anything outside the platform is concerned.
326
+ if (/(^|\/)secrets\.json\./.test(norm)) return true;
327
+ if (norm === "trigger-log.jsonl.tmp") return true;
312
328
  return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
313
329
  }
314
330
 
331
+ /** What a deploy keeps whatever it ships — the rule above saveWorkspace's
332
+ * body. planDeploy reports removals through the same test, so a plan never
333
+ * lists a file the save would keep. */
334
+ export function keptOnDeploy(rel: string, shipped: ReadonlySet<string>): boolean {
335
+ return (
336
+ // Platform bookkeeping (the vault, hook rotation state, the delivery
337
+ // log, the trigger log, run history) — losing any of it on deploy
338
+ // silently breaks something: agents lose secrets, a rotated hook
339
+ // un-rotates, the record of why a schedule skipped is gone.
340
+ isPlatformPath(rel) ||
341
+ /(^|\/)state\//.test(rel) ||
342
+ rel === STORAGE_DIR ||
343
+ rel.startsWith(`${STORAGE_DIR}/`) ||
344
+ (/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel))
345
+ );
346
+ }
347
+
315
348
  export function saveWorkspace(
316
349
  tenant: string,
317
350
  workspace: string,
@@ -373,15 +406,7 @@ export function saveWorkspace(
373
406
  // about it. Before this, every deploy silently erased everything the agents
374
407
  // had learned.
375
408
  const shipped = new Set(files.map((f) => path.normalize(f.path)));
376
- const isAgentOwned = (rel: string) =>
377
- // Platform bookkeeping (the vault, hook rotation state, the delivery
378
- // log, run history) — losing any of it on deploy silently breaks
379
- // something: agents lose secrets, a rotated hook un-rotates.
380
- isPlatformPath(rel) ||
381
- /(^|\/)state\//.test(rel) ||
382
- rel === STORAGE_DIR ||
383
- rel.startsWith(`${STORAGE_DIR}/`) ||
384
- (/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel));
409
+ const isAgentOwned = (rel: string) => keptOnDeploy(rel, shipped);
385
410
 
386
411
  // What is there now, for the revision: every editable file's content
387
412
  // before this save replaces the tree.
@@ -399,7 +424,7 @@ export function saveWorkspace(
399
424
  const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
400
425
  const preserved: string[] = [];
401
426
  if (snapshot) {
402
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
427
+ for (const entry of entriesNoFollow(dir)) {
403
428
  const rel = String(entry).split(path.sep).join("/");
404
429
  const from = path.join(dir, rel);
405
430
  try {
@@ -546,10 +571,15 @@ export interface McpSpec {
546
571
  headers: Record<string, string>;
547
572
  }
548
573
 
574
+ /** `outward: true` in a tool's frontmatter: this one sends, posts, buys,
575
+ * publishes or uploads. It changes nothing at run time — `check` uses it to
576
+ * insist the step that grants it has a verify or a gate. */
577
+ export type OutwardMark = { outward?: boolean };
578
+
549
579
  export type ToolDef =
550
- | { kind: "http"; name: string; spec: ApiSpec }
551
- | { kind: "script"; name: string; spec: Record<string, unknown> }
552
- | { kind: "mcp"; name: string; spec: McpSpec };
580
+ | ({ kind: "http"; name: string; spec: ApiSpec } & OutwardMark)
581
+ | ({ kind: "script"; name: string; spec: Record<string, unknown> } & OutwardMark)
582
+ | ({ kind: "mcp"; name: string; spec: McpSpec } & OutwardMark);
553
583
 
554
584
  /**
555
585
  * The program inside a single-file script tool: the first fenced block whose
@@ -585,6 +615,16 @@ export function fencedCodeBlock(
585
615
  return null;
586
616
  }
587
617
 
618
+ /**
619
+ * `outward: true` — this tool changes something that is not ours: it sends,
620
+ * posts, buys, publishes, uploads. The runtime does not treat it differently;
621
+ * `check` does, because the step that uses one is the step where a silent
622
+ * failure costs something that cannot be taken back. See `lintFlow`.
623
+ */
624
+ export function toolIsOutward(data: Record<string, unknown>): boolean {
625
+ return data.outward === true;
626
+ }
627
+
588
628
  export function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null {
589
629
  const name = typeof data.name === "string" ? data.name : fallbackName;
590
630
  // `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
@@ -652,6 +692,76 @@ export function parseToolDef(data: Record<string, unknown>, fallbackName: string
652
692
  *
653
693
  * Flat files keep working unchanged — the shape is a migration, not a break.
654
694
  */
695
+ /**
696
+ * A folder tool's `requirements.txt` and `package.json`, as a `runtime:`
697
+ * block. Requirement lines only: comments and blanks are left out, and so
698
+ * are options — whole option lines (`-r`, `--index-url`, `-e`) and the ones
699
+ * a requirement carries (`--hash=…`). Options never reach the installer,
700
+ * which takes them from the same argv as its operands; their NAMES are kept
701
+ * in `ignored`, never their values (an index URL can carry a password), so
702
+ * the build log can say what was not applied. Anything else that is not a
703
+ * requirement is refused later by parseRuntime with a log line, the same as
704
+ * it would be in tool.md. `dependencies` only — a tool's dev dependencies
705
+ * are its author's, not its runtime's.
706
+ */
707
+ export function folderRuntime(folder: string): Record<string, unknown> | null {
708
+ const out: { packages?: string[]; npm?: string[]; node?: boolean; ignored?: string[] } = {};
709
+ const req = path.join(folder, "requirements.txt");
710
+ if (fs.existsSync(req)) {
711
+ const packages: string[] = [];
712
+ const ignored = new Set<string>();
713
+ // Logical lines: a trailing backslash continues onto the next, which is
714
+ // how pip-compile --generate-hashes writes every pin. Read line by line,
715
+ // `pandas==2.1 \` became `pandas==2.1\`, was refused as invalid, and
716
+ // its hash lines were dropped as options: a hash-pinned file installed
717
+ // nothing at all.
718
+ const logical = fs.readFileSync(req, "utf8").replace(/\\\r?\n/g, " ").split(/\r?\n/);
719
+ for (const raw of logical) {
720
+ const line = raw.replace(/(^|\s)#.*$/, "").trim();
721
+ if (!line) continue;
722
+ // The requirement is what comes before its first option.
723
+ const [head, ...opts] = line.split(/\s+(?=-)/);
724
+ for (const o of line.startsWith("-") ? [line, ...opts] : opts) ignored.add(o.split(/[\s=]/)[0]);
725
+ if (line.startsWith("-")) continue;
726
+ const requirement = head.replace(/\s+/g, "");
727
+ if (requirement) packages.push(requirement);
728
+ }
729
+ if (packages.length) out.packages = packages;
730
+ if (ignored.size) out.ignored = [...ignored];
731
+ }
732
+ const pkg = path.join(folder, "package.json");
733
+ if (fs.existsSync(pkg)) {
734
+ try {
735
+ const deps = (JSON.parse(fs.readFileSync(pkg, "utf8")) as { dependencies?: Record<string, string> }).dependencies ?? {};
736
+ const npm = Object.entries(deps).map(([name, range]) => (range && range !== "*" && range !== "latest" ? `${name}@${range}` : name));
737
+ if (npm.length) {
738
+ out.npm = npm;
739
+ out.node = true;
740
+ }
741
+ } catch {
742
+ // An unreadable package.json declares nothing; tool.md still can.
743
+ }
744
+ }
745
+ return out.packages || out.npm || out.ignored ? out : null;
746
+ }
747
+
748
+ /** tool.md's own `runtime:` plus what the folder's files add. */
749
+ function mergeRuntimeBlocks(own: unknown, extra: Record<string, unknown>): Record<string, unknown> {
750
+ const base = own && typeof own === "object" && !Array.isArray(own) ? { ...(own as Record<string, unknown>) } : {};
751
+ const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : []);
752
+ const packages = [...new Set([...list(base.packages ?? base.pip), ...list(extra.packages)])];
753
+ const npm = [...new Set([...list(base.npm), ...list(extra.npm)])];
754
+ const ignored = [...new Set([...list(base.ignored), ...list(extra.ignored)])];
755
+ delete base.pip;
756
+ return {
757
+ ...base,
758
+ ...(packages.length ? { packages } : {}),
759
+ ...(npm.length ? { npm } : {}),
760
+ ...(ignored.length ? { ignored } : {}),
761
+ ...(extra.node && base.node === undefined ? { node: true } : {}),
762
+ };
763
+ }
764
+
655
765
  function readToolDir(dir: string, scope: "workspace" | "account" = "workspace"): Record<string, ToolDef> {
656
766
  if (!fs.existsSync(dir)) return {};
657
767
  const out: Record<string, ToolDef> = {};
@@ -678,8 +788,17 @@ function readToolDir(dir: string, scope: "workspace" | "account" = "workspace"):
678
788
  if (folder && typeof d.run === "string" && !/^(workspace|account|shared|library)\//.test(d.run)) {
679
789
  d.run = `${scope}/tools/${folder}/${d.run.replace(/^\.\//, "")}`;
680
790
  }
791
+ // The ecosystem's own dependency files, beside the code that needs them,
792
+ // count as `runtime:` — a tool copied out of a repo keeps working.
793
+ if (folder) {
794
+ const extra = folderRuntime(path.join(dir, folder));
795
+ if (extra) d.runtime = mergeRuntimeBlocks(d.runtime, extra);
796
+ }
681
797
  const def = parseToolDef(d, fallbackName, content);
682
- if (def) out[def.name] = def;
798
+ // `outward: true` rides on the definition so a caller that resolved the
799
+ // grants — `check` — can ask which agents can act outside the workspace
800
+ // without reading every tool file a second time.
801
+ if (def) out[def.name] = toolIsOutward(d) ? { ...def, outward: true } : def;
683
802
  else broken.push(`${fallbackName}: no base, run, command or fenced code block — nothing to call`);
684
803
  } catch (err) {
685
804
  // A malformed definition must not fail every run in the workspace. But
@@ -756,7 +875,9 @@ export interface ApiSpec {
756
875
  openapi?: string;
757
876
  /** Which of the document's operations to expose — operationIds or
758
877
  * `"METHOD /path"` strings. Absent: every operation, capped at 60.
759
- * Present: only these, and the generic `call_<api>` tool is withheld. */
878
+ * Present: only these, and the generic `call_<api>` tool is withheld —
879
+ * always. If none of them resolve (document missing, moved, or an
880
+ * operation renamed) the API gets no tools that run: it fails closed. */
760
881
  operations?: string[];
761
882
  /** Calls per second this API may see from one step: `count` is the burst
762
883
  * the bucket holds, `perSec` the refill rate. `"5/s"` → {5, 5};
@@ -784,9 +905,15 @@ export interface AgentInfo {
784
905
  /** Names still written under the removed `use:` key. Nothing is granted
785
906
  * for them; `foldrun check` and the run log say what to write instead. */
786
907
  legacyUse: string[];
908
+ /** Retired built-in names still in `tools:` (files, bash) — granted, but
909
+ * `check` says what to write instead. */
910
+ retiredTools: string[];
787
911
  /** Colleagues this agent may consult mid-run (`agents:`) — each becomes a
788
912
  * consult_<name> tool. The team's edges, as the author drew them. */
789
913
  consults: string[];
914
+ /** Colleagues this agent may DELEGATE to (`subagents:`) — each runs with
915
+ * its own context and its own tools, no wider than this agent's. */
916
+ subagents: string[];
790
917
  /** The `skills:` allowlist, or null when the field is absent — which is
791
918
  * not the same thing: absent inherits every skill in scope, an empty
792
919
  * list withholds all of them. */
@@ -798,6 +925,32 @@ export interface AgentInfo {
798
925
  budget: number | null;
799
926
  /** What is wrong with the `budget:` line, or null. */
800
927
  budgetProblem: string | null;
928
+ /** `timezone:` — the calendar this agent works to, nearest-wins over its
929
+ * flow's, its workspace's and the account's. Null inherits. */
930
+ timezone: string | null;
931
+ /** What is wrong with the `timezone:` line, or null. */
932
+ timezoneProblem: string | null;
933
+ languageProblem: string | null;
934
+ /** region:/currency:/units:/calendar: values that cannot be read. */
935
+ localeProblems: string[];
936
+ /** `web:` values that cannot work. The run says
937
+ * so in its trail and carries on; check and deploy say so first. */
938
+ webProblems: string[];
939
+ /** `web:` that works but should change — the old `session:` key, a pinned
940
+ * user agent behind the runner image's Chrome (when this process knows
941
+ * that Chrome: FOLDRUN_RUNNER_ENGINES). Warnings, never refusals. */
942
+ webWarnings: string[];
943
+ /** `schedule:` written on an agent, which nothing runs — only a flow has
944
+ * a clock. The message, or null when there is no such line. */
945
+ scheduleProblem: string | null;
946
+ /** `limits:` — the agent's own per-step call limits, as readable. */
947
+ limits: Record<string, number>;
948
+ /** What is wrong with the `limits:` block's shape; key-vs-grant problems
949
+ * need the tool lists, and `check` adds them (limits.ts limitKeyProblems). */
950
+ limitProblems: string[];
951
+ /** Tools this agent declares inline rather than in `tools:` — `apis:`,
952
+ * `scripts:` and `mcpServers:` names — which a limit may also name. */
953
+ inlineTools: string[];
801
954
  }
802
955
 
803
956
  // Recognised HTTP verbs a tool may declare. Deliberately NOT named
@@ -1001,6 +1154,11 @@ export function parseApis(raw: unknown): ApiSpec[] {
1001
1154
  export interface FlowStep {
1002
1155
  agent: string;
1003
1156
  instruction: string;
1157
+ /** The instruction was written over more than one line and the parser
1158
+ * joined them. It runs, but `foldrun check` says so: a wrapped
1159
+ * instruction is one editor keystroke away from being a paragraph of
1160
+ * prose the parser has to guess about. */
1161
+ wrapped?: boolean;
1004
1162
  group: number; // steps sharing a group number run in parallel
1005
1163
  optional: boolean; // "2?" — failure doesn't fail the flow
1006
1164
  /** Set when the step targets another flow (`[[flow:name]]`) instead of an agent. */
@@ -1095,6 +1253,9 @@ export interface FlowStep {
1095
1253
  /** `max_turns:` — the most model turns this step may take before it is
1096
1254
  * stopped, beside `budget:` (money) and `timeout:` (time). */
1097
1255
  maxTurns?: number;
1256
+ /** `limits: {web.search: 10}` — this step's call limits, over the agent's
1257
+ * (nearest wins per key). See limits.ts. */
1258
+ limits?: Record<string, number>;
1098
1259
  /** 1-indexed line in the flow file. Diagnostics without a line make you
1099
1260
  * search; every real linter emits file:line. */
1100
1261
  line?: number;
@@ -1141,7 +1302,10 @@ export interface FlowInfo {
1141
1302
  * with `on:` saying which endings count. The finished run's summary and
1142
1303
  * result become this run's task. */
1143
1304
  after: string | null;
1144
- on: "completed" | "failed" | "any";
1305
+ /** `blocked` is a completed run whose verdict is BLOCKED — the work
1306
+ * refused itself. `completed` no longer includes those: a follow-up to a
1307
+ * publish that did not happen should not run as if it had. */
1308
+ on: "completed" | "failed" | "blocked" | "any";
1145
1309
  /** `trigger: once` — an ISO 8601 instant; fires the first tick at or after
1146
1310
  * it, once, then never again. */
1147
1311
  at: string | null;
@@ -1197,6 +1361,13 @@ export interface FlowInfo {
1197
1361
  * failed all week is not going to fix itself, and every one of those runs
1198
1362
  * costs money and sends mail. Null: never quarantined. */
1199
1363
  disableAfter: number | null;
1364
+ /** `pause_when:` — a marker (DONE, NOTHING LEFT) that, leading a line of
1365
+ * any step's reply in the flow's latest run, pauses its schedule: the
1366
+ * work is finished, so firing again would only spend money saying so.
1367
+ * Derived from that run, like disable_after, so a run that finds work
1368
+ * again (a person clicking Run after adding some) lifts it by itself.
1369
+ * Null: never paused. */
1370
+ pauseWhen: string | null;
1200
1371
  /** `sla:` — seconds a run of this flow is expected to take. A run still
1201
1372
  * going well past it is reported once, as a notification, and keeps
1202
1373
  * running: this is a smoke alarm, not a timeout. Null: no expectation. */
@@ -1220,6 +1391,17 @@ export interface FlowInfo {
1220
1391
  * Only ever an opt-out of the safe default, never the other way round. */
1221
1392
  live: boolean;
1222
1393
 
1394
+ /** `description:` — one line on what this flow is for, shown in listings
1395
+ * and the trace. Read here so a documented, scaffolded field means
1396
+ * something rather than sitting in every file unread. */
1397
+ description: string | null;
1398
+
1399
+ /** Flow-level frontmatter values the parser could not read — a bad
1400
+ * `overlap:`/`priority:`/`catchup:`/`on:`/`signature:`, or a step line
1401
+ * whose `[[target]]` is malformed. Errors, the way a bad step option is,
1402
+ * rather than a silent default. */
1403
+ frontProblems: string[];
1404
+
1223
1405
  steps: FlowStep[];
1224
1406
  }
1225
1407
 
@@ -1270,6 +1452,14 @@ export function agentAssets(tenant: string, workspace: string, agent: string): A
1270
1452
  return { skills, memory };
1271
1453
  }
1272
1454
 
1455
+ /** `schedule:` on an agent runs nothing — only a flow has a clock. Named so
1456
+ * it fails loudly rather than deploying clean and never firing. */
1457
+ function agentScheduleProblem(data: Record<string, unknown>): string | null {
1458
+ return data.schedule !== undefined
1459
+ ? "schedule: an agent has no schedule of its own — only a flow runs on a clock. Put schedule: on the flow that runs this agent, or remove it."
1460
+ : null;
1461
+ }
1462
+
1273
1463
  /** A per-run `budget:` as a number, or null. A period other than "run"
1274
1464
  * written here is a mistake the lint names; it is not silently a cap. */
1275
1465
  function runBudget(raw: unknown): number | null {
@@ -1277,6 +1467,23 @@ function runBudget(raw: unknown): number | null {
1277
1467
  return b && b.period === "run" ? b.usd : null;
1278
1468
  }
1279
1469
 
1470
+ /** The names an agent's inline tool fields grant: `apis:`, `scripts:` (an
1471
+ * object's name, or a file's base name) and `mcpServers:`. */
1472
+ function inlineToolNames(data: Record<string, unknown>): string[] {
1473
+ const out: string[] = [];
1474
+ if (Array.isArray(data.apis)) {
1475
+ for (const a of data.apis) if (a && typeof a === "object" && typeof (a as { name?: unknown }).name === "string") out.push((a as { name: string }).name);
1476
+ }
1477
+ if (Array.isArray(data.scripts)) {
1478
+ for (const sc of data.scripts) {
1479
+ if (typeof sc === "string") out.push(path.basename(sc).replace(/\.[^.]+$/, ""));
1480
+ else if (sc && typeof sc === "object" && typeof (sc as { name?: unknown }).name === "string") out.push((sc as { name: string }).name);
1481
+ }
1482
+ }
1483
+ if (data.mcpServers && typeof data.mcpServers === "object") out.push(...Object.keys(data.mcpServers));
1484
+ return out;
1485
+ }
1486
+
1280
1487
  export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1281
1488
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
1282
1489
  if (!fs.existsSync(dir)) return [];
@@ -1296,11 +1503,22 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1296
1503
  apis: parseApis(data.apis),
1297
1504
  ownTools: ownToolNames(data),
1298
1505
  legacyUse: legacyUseNames(data),
1506
+ retiredTools: retiredToolNames(data),
1299
1507
  consults: refNames(data.agents),
1508
+ subagents: refNames(data.subagents),
1300
1509
  skills: data.skills === undefined ? null : refNames(data.skills),
1301
1510
  secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
1302
1511
  budget: runBudget(data.budget),
1303
1512
  budgetProblem: budgetProblem(data.budget, ["run"]),
1513
+ timezone: typeof data.timezone === "string" ? data.timezone : null,
1514
+ timezoneProblem: timezoneProblem(data.timezone),
1515
+ languageProblem: languageProblem(data.language),
1516
+ localeProblems: localeProblems(data),
1517
+ webProblems: [...webProblems(data), ...actionProblems(data)],
1518
+ webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
1519
+ scheduleProblem: agentScheduleProblem(data),
1520
+ ...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
1521
+ inlineTools: inlineToolNames(data),
1304
1522
  };
1305
1523
  });
1306
1524
  }
@@ -1324,10 +1542,16 @@ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
1324
1542
  * swallowed as an unknown option, silently, along with any typo of a real
1325
1543
  * key. The consistency suite reads this list against the docs. */
1326
1544
  const STEP_OPTION_KEYS = new Set([
1327
- "approve", "ask", "case", "delegate", "each", "effort", "else", "loop", "max", "max_turns", "model",
1545
+ "approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
1328
1546
  "on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
1329
1547
  ]);
1330
1548
 
1549
+ /** A line that opens a markdown block rather than continuing a sentence:
1550
+ * a heading, a bullet, a numbered item, a quote, a rule, or a paragraph
1551
+ * that opens in bold. Tested against the line already trimmed, so it says
1552
+ * "this is a document" whatever margin the list gave it. */
1553
+ const MARKDOWN_BLOCK_RE = /^(?:#{1,6}\s|[-*+]\s|>\s|\d+[.)]\s|\*\*|__|---|\|)/;
1554
+
1331
1555
  /** The most fan-out instances `parallel:` may run at once — the fan-out's
1332
1556
  * own hard cap, since more slots than items is no cap. */
1333
1557
  export const PARALLEL_CAP = 20;
@@ -1389,7 +1613,7 @@ export function parseInstant(raw: unknown): string | null {
1389
1613
  * removed a leading OR a trailing quote independently, so a value that
1390
1614
  * merely *ended* with one lost it — and
1391
1615
  *
1392
- * verify: test "$(cat ../../storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
1616
+ * verify: test "$(cat workspace/storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
1393
1617
  *
1394
1618
  * reached bash unbalanced, which exited 2 with "unexpected EOF" on every
1395
1619
  * run. The step could never pass, whatever the agent did, and nothing said
@@ -1430,6 +1654,17 @@ export function unquote(raw: string): string {
1430
1654
  * The marker must also END at a boundary, so BLOCKED does not match
1431
1655
  * BLOCKEDBY.
1432
1656
  */
1657
+ /**
1658
+ * The path of a `when: rows of <path>` condition, or null when the condition
1659
+ * is a marker. `rows of` is the phrase `each:` uses for the same file, so a
1660
+ * flow reads a CSV one way in both. The whole phrase is required: a bare
1661
+ * `when: ROWS` stays the marker it always was.
1662
+ */
1663
+ export function whenRowsPath(when: string): string | null {
1664
+ const m = when.trim().match(/^rows\s+of\s+(\S.*)$/i);
1665
+ return m ? m[1].trim() : null;
1666
+ }
1667
+
1433
1668
  export function markerPresent(text: string | null | undefined, marker: string): boolean {
1434
1669
  const needle = marker.trim().toLowerCase();
1435
1670
  if (!needle || !text) return false;
@@ -1449,8 +1684,15 @@ export function markerPresent(text: string | null | undefined, marker: string):
1449
1684
  }
1450
1685
 
1451
1686
  export function parseFlow(file: string, raw: string): FlowInfo {
1687
+ // A flow saved with CRLF (Windows, some editors) read as no steps at all:
1688
+ // every line kept its "\r", which STEP_RE's `.` will not match. Read it as
1689
+ // LF; the line count, and so every step's `line`, is the same.
1690
+ raw = raw.replace(/\r\n/g, "\n");
1452
1691
  const { data, content } = matter(raw);
1453
1692
  const steps: FlowStep[] = [];
1693
+ // Flow-level problems: a malformed step target (below) and unreadable
1694
+ // frontmatter enums (before the return). Reported as errors by flow-lint.
1695
+ const frontProblems: string[] = [];
1454
1696
  // Frontmatter is stripped by matter(), so add it back to keep line numbers
1455
1697
  // pointing at the real file rather than the body.
1456
1698
  const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
@@ -1460,6 +1702,9 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1460
1702
  // shape to be written out rather than named, because a schema is a
1461
1703
  // document and a one-line one is unreadable past three fields.
1462
1704
  let block: { step: FlowStep; indent: number; lines: string[] } | null = null;
1705
+ // The step still accepting wrapped instruction lines, or null once
1706
+ // anything has closed it. See the continuation rule below.
1707
+ let open: FlowStep | null = null;
1463
1708
  const endBlock = () => {
1464
1709
  if (!block) return;
1465
1710
  const { step, lines } = block;
@@ -1500,26 +1745,51 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1500
1745
  instruction: m[5].trim(),
1501
1746
  line: lineNo + offset,
1502
1747
  });
1748
+ open = steps[steps.length - 1];
1503
1749
  continue;
1504
1750
  }
1505
- // An indented line that is not an option continues the instruction.
1751
+ // A line shaped like a numbered step whose `[[target]]` STEP_RE would
1752
+ // not accept — capitals or an underscore in the name — used to fall
1753
+ // through and be swallowed as prose, so the step silently vanished.
1754
+ // Name it instead: an agent is [a-z0-9-], and a real one that is merely
1755
+ // missing is a separate, later error.
1756
+ const shape = line.match(/^\s*\d+[?!]?\.?\s+\[\[(flow:)?([^\]]*)\]\]/);
1757
+ if (shape && !STEP_RE.test(line)) {
1758
+ frontProblems.push(`step \`[[${shape[1] ?? ""}${shape[2]}]]\` on line ${lineNo + offset}: a step targets an agent or flow named in lower case, digits and dashes — not \`${shape[2]}\`.`);
1759
+ }
1760
+ // An indented line that is not an option continues the instruction —
1761
+ // but only while the step is still open.
1506
1762
  //
1507
- // Without this it was silently DROPPED. An instruction wrapped over
1508
- // three lines kept the first and discarded the rest, the run went
1509
- // green, and the agent worked from an instruction its author had not
1510
- // written — the worst failure this parser can have, because nothing
1511
- // anywhere reports it. Twenty-five live instructions across eight desks
1512
- // were being cut this way.
1763
+ // Both directions of this have been wrong in production. Dropping the
1764
+ // continuation silently cut twenty-five live instructions off mid
1765
+ // sentence. Taking every indented line instead read the *wrapped lines
1766
+ // of a bullet list* three paragraphs below the last step as orders: the
1767
+ // gbp-desk publisher was told to "publish Tuesday's five every way step
1768
+ // 3 could fail was also a way Thursday's five silently never went out".
1513
1769
  //
1514
- // Indentation is what makes it a continuation, exactly as it is for an
1515
- // option. Unindented prose between steps is still prose and still
1516
- // ignored, so a flow file's commentary is undisturbed.
1770
+ // So a step's instruction ends where the step ends. A continuation is
1771
+ // an indented line that follows the step line, or a previous
1772
+ // continuation, with nothing in between — no blank line, no unindented
1773
+ // line, no option, no schema block. Once any of those closes the step
1774
+ // the instruction is finished, and prose below the last step is prose.
1775
+ //
1776
+ // Indented markdown never continues an instruction either. A heading, a
1777
+ // bullet, a quote, a numbered item or a paragraph opening in bold is a
1778
+ // document, not a sentence somebody wrapped, and the indentation is a
1779
+ // list's own margin.
1517
1780
  const opt = line.match(OPTION_RE);
1518
- if (steps.length && /^\s+\S/.test(line) && !(opt && STEP_OPTION_KEYS.has(opt[2]))) {
1519
- const step = steps[steps.length - 1];
1520
- step.instruction = `${step.instruction} ${line.trim()}`.trim();
1521
- continue;
1781
+ const isOption = !!opt && STEP_OPTION_KEYS.has(opt[2]);
1782
+ if (open && /^\s+\S/.test(line) && !isOption) {
1783
+ if (MARKDOWN_BLOCK_RE.test(line.trim())) {
1784
+ open = null;
1785
+ } else {
1786
+ open.instruction = `${open.instruction} ${line.trim()}`.trim();
1787
+ open.wrapped = true;
1788
+ continue;
1789
+ }
1522
1790
  }
1791
+ // Anything that is not a continuation closes the step to further ones.
1792
+ open = null;
1523
1793
  // Indented options belong to the step above them.
1524
1794
  if (opt && steps.length) {
1525
1795
  const step = steps[steps.length - 1];
@@ -1562,9 +1832,9 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1562
1832
  else if (value === "items") step.each = "items";
1563
1833
  else if (/^rows\b/.test(value)) {
1564
1834
  step.each = "rows";
1565
- // "rows of ../../storage/leads.csv" — the path is the part after "of".
1835
+ // "rows of workspace/storage/leads.csv" — the path is the part after "of".
1566
1836
  step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
1567
- if (!step.eachPath) problem("rows needs a file: each: rows of ../../storage/leads.csv");
1837
+ if (!step.eachPath) problem("rows needs a file: each: rows of workspace/storage/leads.csv");
1568
1838
  }
1569
1839
  else problem("lines, items, or rows of <path>");
1570
1840
  }
@@ -1578,7 +1848,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1578
1848
  }
1579
1849
  else if (key === "ask") { step.ask = value; }
1580
1850
  else if (key === "preview") {
1581
- step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/)?storage\//, "")).filter(Boolean);
1851
+ step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/|workspace\/)?storage\//, "")).filter(Boolean);
1582
1852
  }
1583
1853
  else if (key === "delegate") step.delegate = refNames(value).slice(0, 5);
1584
1854
  else if (key === "max") {
@@ -1596,6 +1866,11 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1596
1866
  if (n === undefined) problem(`a whole number of turns, 1 to ${MAX_TURNS_CAP}`);
1597
1867
  else step.maxTurns = n;
1598
1868
  }
1869
+ else if (key === "limits") {
1870
+ const read = parseStepLimits(value);
1871
+ for (const p of read.problems) problem(p);
1872
+ if (Object.keys(read.limits).length) step.limits = read.limits;
1873
+ }
1599
1874
  else if (key === "schema") {
1600
1875
  if (!value) block = { step, indent: indentText.length, lines: [] };
1601
1876
  else if (/^[{[]/.test(value)) {
@@ -1609,6 +1884,21 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1609
1884
  }
1610
1885
  }
1611
1886
  endBlock();
1887
+ // A frontmatter enum written with a typo — `overlap: que` — read as null
1888
+ // and ran with the silent default. flows.md promises otherwise ("a value
1889
+ // the option cannot read is a check error, never a guess"); make it true
1890
+ // for these too, not only for step options.
1891
+ const badEnum = (key: string, val: unknown, allowed: string[]) => {
1892
+ if (typeof val === "string" && val.trim() && !allowed.includes(val.trim())) {
1893
+ frontProblems.push(`${key}: ${val} — the values are ${allowed.join(", ")}.`);
1894
+ }
1895
+ };
1896
+ badEnum("overlap", data.overlap, ["skip", "queue"]);
1897
+ badEnum("priority", data.priority, ["high", "normal", "low"]);
1898
+ badEnum("catchup", data.catchup, ["none", "last"]);
1899
+ badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
1900
+ badEnum("signature", data.signature, ["github", "stripe", "slack", "hmac"]);
1901
+
1612
1902
  steps.sort((a, b) => a.group - b.group);
1613
1903
  return {
1614
1904
  name: data.name ?? file.replace(/\.md$/, ""),
@@ -1626,7 +1916,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1626
1916
  // `after: [[flow:publish]]` — a link or a bare name, read the same way
1627
1917
  // as every other file-naming field (refs.ts).
1628
1918
  after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
1629
- on: data.on === "failed" || data.on === "any" ? data.on : "completed",
1919
+ on: data.on === "failed" || data.on === "blocked" || data.on === "any" ? data.on : "completed",
1630
1920
  at: parseInstant(data.at),
1631
1921
  url: typeof data.url === "string" ? data.url.trim() : null,
1632
1922
  every: typeof data.every === "string" || typeof data.every === "number" ? (parseWait(String(data.every)) ?? null) : null,
@@ -1648,9 +1938,14 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1648
1938
  disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
1649
1939
  ? Math.min(100, Math.floor(Number(data.disable_after)))
1650
1940
  : null,
1941
+ pauseWhen: typeof data.pause_when === "string" && data.pause_when.trim() && data.pause_when.trim().length <= 60
1942
+ ? data.pause_when.trim()
1943
+ : null,
1651
1944
  sla: durationOf(data.sla),
1652
1945
  approvers: approverList(data.approvers),
1653
1946
  approveWithin: durationOf(data.approve_within),
1947
+ description: typeof data.description === "string" ? data.description.trim() || null : null,
1948
+ frontProblems,
1654
1949
  steps,
1655
1950
  };
1656
1951
  }
@@ -1778,7 +2073,7 @@ function emitFlow(head: string, preamble: string[], groups: FlowBlock[][]): stri
1778
2073
  * and the diff is exactly the lines that changed. A flow with no
1779
2074
  * frontmatter gains the smallest one that can carry the trigger.
1780
2075
  */
1781
- export function setFlowTrigger(
2076
+ function setFlowTriggerLF(
1782
2077
  raw: string,
1783
2078
  opts: { trigger: FlowTrigger; schedule?: string; timezone?: string },
1784
2079
  ): string {
@@ -1838,7 +2133,7 @@ export function setFlowTrigger(
1838
2133
  * the prefix is taken verbatim from the file and only the tail replaced. A
1839
2134
  * flow's markdown stays the author's.
1840
2135
  */
1841
- export function updateFlowStepInstruction(raw: string, index: number, instruction: string): string {
2136
+ function updateFlowStepInstructionLF(raw: string, index: number, instruction: string): string {
1842
2137
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1843
2138
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
1844
2139
  throw new Error(`no step ${index}`);
@@ -1862,10 +2157,10 @@ export function updateFlowStepInstruction(raw: string, index: number, instructio
1862
2157
  return emitFlow(head, preamble, groups);
1863
2158
  }
1864
2159
 
1865
- export function updateFlowStep(
2160
+ function updateFlowStepLF(
1866
2161
  raw: string,
1867
2162
  index: number,
1868
- options: Partial<Record<"model" | "effort" | "retry" | "timeout" | "verify" | "when" | "case" | "else" | "loop" | "until" | "each" | "max", string | number | null>>,
2163
+ options: Partial<Record<"model" | "effort" | "retry" | "timeout" | "verify" | "when" | "case" | "else" | "loop" | "until" | "each" | "max" | "limits", string | number | null>>,
1869
2164
  ): string {
1870
2165
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1871
2166
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
@@ -1885,6 +2180,15 @@ export function updateFlowStep(
1885
2180
  loop: (v) => (Number(v) > 0 ? String(Math.min(5, Math.max(1, Math.floor(Number(v))))) : null),
1886
2181
  max: (v) => (Number(v) > 0 ? String(Math.min(20, Math.max(1, Math.floor(Number(v))))) : null),
1887
2182
  each: (v) => (v === "lines" ? "lines" : null),
2183
+ // `{web.search: 10, calls: 300}` — one line, read the way parseFlow
2184
+ // reads it; a value it would flag is refused rather than written.
2185
+ limits: (v) => {
2186
+ if (!v || v === "{}") return null;
2187
+ const read = parseStepLimits(v);
2188
+ if (read.problems.length) throw new Error(`limits: ${read.problems[0]}`);
2189
+ const pairs = Object.entries(read.limits);
2190
+ return pairs.length ? `{${pairs.map(([k, n]) => `${k}: ${n}`).join(", ")}}` : null;
2191
+ },
1888
2192
  };
1889
2193
 
1890
2194
  const block = blocks[index];
@@ -1912,7 +2216,7 @@ export function updateFlowStep(
1912
2216
  return emitFlow(head, preamble, groups);
1913
2217
  }
1914
2218
 
1915
- export function reorderFlowSteps(raw: string, groups: number[][]): string {
2219
+ function reorderFlowStepsLF(raw: string, groups: number[][]): string {
1916
2220
  const { head, preamble, blocks: ordered } = splitFlowBlocks(raw);
1917
2221
 
1918
2222
  const seen = new Set<number>();
@@ -1932,7 +2236,7 @@ export function reorderFlowSteps(raw: string, groups: number[][]): string {
1932
2236
  }
1933
2237
 
1934
2238
  /** Append a step as its own group at the end of the flow. */
1935
- export function addFlowStep(
2239
+ function addFlowStepLF(
1936
2240
  raw: string,
1937
2241
  step: { target: string; subflow?: boolean; instruction?: string },
1938
2242
  ): string {
@@ -1947,7 +2251,7 @@ export function addFlowStep(
1947
2251
  }
1948
2252
 
1949
2253
  /** Drop one step, by its index in the flow's parsed steps. */
1950
- export function removeFlowStep(raw: string, index: number): string {
2254
+ function removeFlowStepLF(raw: string, index: number): string {
1951
2255
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1952
2256
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
1953
2257
  throw new Error(`no step ${index}`);
@@ -1956,6 +2260,20 @@ export function removeFlowStep(raw: string, index: number): string {
1956
2260
  return emitFlow(head, preamble, groupBlocks(kept));
1957
2261
  }
1958
2262
 
2263
+ // The rewriters above work on LF (eol.ts); the web route calls these with
2264
+ // the file as saved, so a CRLF flow is read as LF and written back as CRLF.
2265
+ export const setFlowTrigger = (raw: string, opts: Parameters<typeof setFlowTriggerLF>[1]): string =>
2266
+ asRaw(raw, setFlowTriggerLF(toLF(raw), opts));
2267
+ export const updateFlowStepInstruction = (raw: string, index: number, instruction: string): string =>
2268
+ asRaw(raw, updateFlowStepInstructionLF(toLF(raw), index, instruction));
2269
+ export const updateFlowStep = (raw: string, index: number, options: Parameters<typeof updateFlowStepLF>[2]): string =>
2270
+ asRaw(raw, updateFlowStepLF(toLF(raw), index, options));
2271
+ export const reorderFlowSteps = (raw: string, groups: number[][]): string =>
2272
+ asRaw(raw, reorderFlowStepsLF(toLF(raw), groups));
2273
+ export const addFlowStep = (raw: string, step: Parameters<typeof addFlowStepLF>[1]): string =>
2274
+ asRaw(raw, addFlowStepLF(toLF(raw), step));
2275
+ export const removeFlowStep = (raw: string, index: number): string => asRaw(raw, removeFlowStepLF(toLF(raw), index));
2276
+
1959
2277
  /** Blocks → the group structure they currently describe. */
1960
2278
  function groupBlocks(blocks: FlowBlock[]): FlowBlock[][] {
1961
2279
  const map = new Map<number, FlowBlock[]>();
@@ -2125,18 +2443,27 @@ function assertEditablePath(rel: string) {
2125
2443
  * never be served. Listing them is honest — hiding them is what makes a file
2126
2444
  * tree disagree with the filesystem it claims to show — but their contents
2127
2445
  * are the vault and the money, so a reader gets the reason instead. */
2128
- const ACCOUNT_SEALED = new Set(["secrets.json", "oauth-clients.json", "ledger.jsonl"]);
2446
+ // billing.json, oauth-connections.json and secret-health.json were listed and
2447
+ // served until 2026-09-24: any member of the account, a viewer included, could
2448
+ // open the Stripe customer id and billing email, and which connections exist.
2449
+ const ACCOUNT_SEALED: Record<string, string> = {
2450
+ "secrets.json": "the vault — encrypted, and never served to a browser. Manage secrets in Settings.",
2451
+ "oauth-clients.json": "OAuth client credentials — never served to a browser.",
2452
+ "ledger.jsonl": "the billing ledger — append-only, read it through Usage and Wallet.",
2453
+ "billing.json": "the account's billing settings — read and change them in Wallet.",
2454
+ "oauth-connections.json": "connected apps and their health — see Settings → Connections.",
2455
+ "secret-health.json": "secret usage bookkeeping — see Settings → Secrets.",
2456
+ };
2129
2457
 
2130
2458
  export function accountFileSealed(rel: string): string | null {
2131
2459
  const norm = rel.replaceAll("\\", "/");
2132
- if (ACCOUNT_SEALED.has(norm)) {
2133
- return norm === "secrets.json"
2134
- ? "the vault — encrypted, and never served to a browser. Manage secrets in Settings."
2135
- : norm === "oauth-clients.json"
2136
- ? "OAuth client credentials — never served to a browser."
2137
- : "the billing ledger — append-only, read it through Usage and Wallet.";
2138
- }
2139
- if (/^(billed|workspaces\/[^/]+\/runs)\//.test(norm)) {
2460
+ if (/(^|\/)secrets\.json\.[^/]+$/.test(norm)) return "the vault's lock or a save in flight — never served.";
2461
+ if (Object.hasOwn(ACCOUNT_SEALED, norm)) return ACCOUNT_SEALED[norm];
2462
+ // What a move into the database leaves behind: billing.json.imported-<date>
2463
+ // and the like, kept for a rollback — the same contents, so the same seal.
2464
+ const moved = /^(.+\.json)\.imported-[\d-]+$/.exec(norm);
2465
+ if (moved && Object.hasOwn(ACCOUNT_SEALED, moved[1])) return ACCOUNT_SEALED[moved[1]];
2466
+ if (/^(billed|once|topups|workspaces\/[^/]+\/runs)\//.test(norm)) {
2140
2467
  return "generated bookkeeping, not an authored file.";
2141
2468
  }
2142
2469
  if (/^workspaces\/[^/]+\/secrets\.json$/.test(norm)) {
@@ -2151,7 +2478,7 @@ export function accountFileSealed(rel: string): string | null {
2151
2478
  * honesty's clothes. `accountFileSealed` still refuses these if a path is
2152
2479
  * constructed directly — the listing hides them, the reader guards them. */
2153
2480
  const ACCOUNT_BOOKKEEPING =
2154
- /^(billed\/|ledger\.jsonl$|oauth-clients\.json$)|(^|\/)runs\/|(^|\/)secrets\.json$/;
2481
+ /^(billed\/|once\/|topups\/|ledger\.jsonl$|oauth-clients\.json$|billing\.json$|oauth-connections\.json$|secret-health\.json$|[^/]+\.json\.imported-[\d-]+$)|(^|\/)runs\/|(^|\/)secrets\.json(\.[^/]+)?$/;
2155
2482
 
2156
2483
  /**
2157
2484
  * The account directory as it actually is on disk.
@@ -2218,7 +2545,7 @@ export function listWorkspaceFiles(tenant: string, workspace: string): string[]
2218
2545
  const dir = workspaceDir(tenant, workspace);
2219
2546
  if (!fs.existsSync(dir)) return [];
2220
2547
  const out: string[] = [];
2221
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
2548
+ for (const entry of entriesNoFollow(dir)) {
2222
2549
  const rel = String(entry);
2223
2550
  if (/(^|\/)(runs|outputs)\//.test(rel)) continue;
2224
2551
  // Skip anything reached through a symlink — a stray link would otherwise
@@ -2264,7 +2591,9 @@ export function writeWorkspaceFile(
2264
2591
  content: string,
2265
2592
  meta: ChangeMeta = {},
2266
2593
  ) {
2267
- if (content.length > 256 * 1024) throw new Error("file too large");
2594
+ // 512 KB: a folder tool's program is one file, and the gallery browser's is
2595
+ // past 256 KB. Still a file a person edits, not a store for data.
2596
+ if (content.length > MAX_EDITABLE_FILE) throw new Error("file too large");
2268
2597
  matter(content); // reject files whose frontmatter doesn't parse
2269
2598
  const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
2270
2599
  const existed = fs.existsSync(p);
@@ -2445,9 +2774,11 @@ export function deleteWorkspacePath(tenant: string, workspace: string, rel: stri
2445
2774
  }
2446
2775
  // Whole-folder units: an agent, a skill, a folder tool. Each is one thing
2447
2776
  // to its reader, so each is one thing to delete.
2448
- if (/^agents\/[a-z0-9-]+$/.test(norm) ||
2449
- /^(skills|tools)\/[a-z0-9-]+$/.test(norm) ||
2450
- /^agents\/[a-z0-9-]+\/skills\/[a-z0-9-]+$/.test(norm)) {
2777
+ // An underscore is allowed in the folder's name: a tool called
2778
+ // `my_tool` is one thing too, and its tool.md is refused alone above.
2779
+ if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
2780
+ /^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
2781
+ /^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
2451
2782
  const gone = deletionsUnder(dir, norm);
2452
2783
  fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
2453
2784
  recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
@@ -2629,13 +2960,53 @@ export interface RunEvent {
2629
2960
  * state write, a withheld secret. The run page lists these; the text is
2630
2961
  * still the trace's line. See test-mode.ts. */
2631
2962
  effect?: TestEffect;
2963
+ /** Set when a person was involved mid-step: a question the agent asked
2964
+ * (ask_person), its answer, or a message a person sent in (operator.ts). */
2965
+ operator?: OperatorEvent;
2966
+ /** Set on a tool event a SUB-AGENT made (`subagents:`): its name. The
2967
+ * trace labels the call with it; cost stays the step's. */
2968
+ subagent?: string;
2969
+ /** Set on an error a failed check wrote — `verify:` (its headline and its
2970
+ * detail), `output: json`, a schema. What the runner reads to decline a
2971
+ * retry of an outward step whose tools already ran. */
2972
+ check?: boolean;
2973
+ }
2974
+
2975
+ /** A question an agent asked a person while its step ran (tools: [ask]). */
2976
+ export interface StepQuestion {
2977
+ id: string;
2978
+ question: string;
2979
+ options?: string[];
2980
+ askedAt: string;
2981
+ answer?: string;
2982
+ answeredAt?: string;
2983
+ by?: string | null;
2984
+ /** true when the wait ran out with no answer. */
2985
+ unanswered?: boolean;
2986
+ }
2987
+
2988
+ /** A message a person sent into a running step, and when the agent got it. */
2989
+ export interface StepMessage {
2990
+ text: string;
2991
+ by?: string | null;
2992
+ at: string;
2993
+ deliveredAt: string;
2632
2994
  }
2633
2995
 
2634
2996
  /** One attempt of a step — see StepRecord.tries. */
2635
2997
  export interface StepAttempt {
2636
- /** Which attempt, 1-based: `attempts` on the step at the time. */
2998
+ /** Which try, 1-based, in the order they ran: a retry is the next try,
2999
+ * and so is the full re-run after a slim go the browser pod was lost
3000
+ * under. Equal to the step's attempt (`attempts` at the time) unless
3001
+ * `attempt` says otherwise. */
2637
3002
  n: number;
2638
- status: "completed" | "failed";
3003
+ /** The step's attempt this try belongs to, written only when it is not
3004
+ * `n` — after a lost slim go, its full re-run is try 2 of attempt 1. */
3005
+ attempt?: number;
3006
+ /** `lost`: a slim go the account's browser pod went away under, which
3007
+ * the step ran again on the full image within the same attempt — a row
3008
+ * of its own (image slim), before the full go's. */
3009
+ status: "completed" | "failed" | "lost";
2639
3010
  costUsd: number | null;
2640
3011
  tokens: { input: number; output: number } | null;
2641
3012
  computeSecs: number | null;
@@ -2643,6 +3014,11 @@ export interface StepAttempt {
2643
3014
  finishedAt: string;
2644
3015
  /** The last error event this attempt wrote, when it failed. */
2645
3016
  error?: string;
3017
+ /** The runner image this attempt ended on, and — when it browsed through
3018
+ * the account's pod from slim — what happened to the pod: reconnects,
3019
+ * the re-run on full, or why it was not re-run. */
3020
+ image?: "full" | "slim";
3021
+ browserPod?: string;
2646
3022
  }
2647
3023
 
2648
3024
  /** The step's totals across its attempts, from the rows. Null where no
@@ -2662,6 +3038,11 @@ export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" |
2662
3038
  export interface StepRecord {
2663
3039
  agent: string;
2664
3040
  instruction: string;
3041
+ /** Questions the agent asked a person mid-step (tools: [ask]), newest
3042
+ * last. One without an answer (and not `unanswered`) is still open. */
3043
+ questions?: StepQuestion[];
3044
+ /** Messages a person sent into the running step, as delivered. */
3045
+ messages?: StepMessage[];
2665
3046
  group: number;
2666
3047
  optional: boolean;
2667
3048
  /** Carried from the flow — see FlowStep. Both are resolved at run
@@ -2699,6 +3080,20 @@ export interface StepRecord {
2699
3080
  /** A size class a retry moved the step up to (an evicted or OOM-killed
2700
3081
  * attempt), overriding the agent's own `size:` for the attempts after. */
2701
3082
  sizeUp?: "large" | "heavy";
3083
+ /** Which runner image the step's sandbox ran: `full` (browsers in it) or
3084
+ * `slim`, why, and `pod` when a browsing step ran slim and browsed
3085
+ * through the account's browser pod. The last attempt's; absent where
3086
+ * the executor has one image (docker, in-process). */
3087
+ image?: { variant: "full" | "slim"; why?: string; pod?: boolean } | null;
3088
+ /** Slim browsing through the account's pod (browser-pod.ts): reconnect
3089
+ * tries and how many got through; when the pod was lost, why and what
3090
+ * the step had written by then; and what happened — `fallback` (the
3091
+ * step had only read and ran again on the full image) or `failure` (it
3092
+ * had written, so it was not re-run). */
3093
+ browserPod?: (BrowserPodOutcome & { fallback?: string; failure?: string }) | null;
3094
+ /** The slim go a lost browser pod ended, set aside while the step re-runs
3095
+ * on full; recordAttempt makes it a `lost` row of `tries` and clears it. */
3096
+ podLostTry?: Omit<StepAttempt, "n" | "attempt" | "startedAt"> | null;
2702
3097
  /** When the step actually started and finished executing — not when the
2703
3098
  * run reached it. A step's events approximate this (first and last), but
2704
3099
  * a model can think for minutes before its first tool call, and a step
@@ -2725,6 +3120,7 @@ export interface StepRecord {
2725
3120
  schemaPath?: string;
2726
3121
  parallel?: number;
2727
3122
  maxTurns?: number;
3123
+ limits?: Record<string, number>;
2728
3124
  /** The parsed JSON an `output: json` step returned. Kept beside the prose
2729
3125
  * result rather than instead of it: the reply is still what a person
2730
3126
  * reads on the run page, and the data is what the next step computes on. */
@@ -2851,6 +3247,20 @@ export interface RunRecord {
2851
3247
  * simply gets its first line — which is still better than a status.
2852
3248
  */
2853
3249
  summary?: string | null;
3250
+ /**
3251
+ * What the run's work concluded, when its summary leads with one of the
3252
+ * four verdict words every desk ends on: GOOD, BAD, QUIET, BLOCKED.
3253
+ *
3254
+ * `status` says whether the machine ran — a step crashed, a budget ran
3255
+ * out. The verdict says what the agents found when it did. They are
3256
+ * different facts: a publisher that checks, sees the wrong change in
3257
+ * front of it and refuses to push has run perfectly, and its run is
3258
+ * `completed`. Until this field, that run was a green tick beside a line
3259
+ * reading BLOCKED (run-mu4sdg8z-1otr, 2026-09-17) — the one outcome a
3260
+ * person most needed to see, drawn as success. Null: the summary does
3261
+ * not lead with a verdict, and the run reads exactly as it always did.
3262
+ */
3263
+ verdict?: Verdict | null;
2854
3264
  /** The flow's per-run `budget:` at the time this run started, so the cap
2855
3265
  * a run was held to is on its record rather than read from a file that
2856
3266
  * may since have changed. Null: none. */
@@ -3381,6 +3791,28 @@ export function runFailure(run: RunRecord): { agent: string; reason: string } |
3381
3791
  * runFailure, which puts the reason for a failure on the list for the same
3382
3792
  * reason — the sentence was in the record all along.
3383
3793
  */
3794
+ export const VERDICTS = ["GOOD", "BAD", "QUIET", "BLOCKED"] as const;
3795
+ export type Verdict = (typeof VERDICTS)[number];
3796
+
3797
+ /**
3798
+ * The verdict a headline leads with, or null. Leading, whole-word and
3799
+ * upper-case only — "GOOD — 6 links applied", "**BLOCKED**: nothing
3800
+ * pushed", "BAD: 3 pages dropped". A summary that merely contains the word
3801
+ * ("no BLOCKED items") has no verdict, for the reason markerPresent gives.
3802
+ */
3803
+ export function verdictOf(summary: string | null | undefined): Verdict | null {
3804
+ if (!summary) return null;
3805
+ const line = summary.replace(/^[\s#>*_`\-]+/, "");
3806
+ const m = /^(GOOD|BAD|QUIET|BLOCKED)(?![A-Za-z0-9_])/.exec(line);
3807
+ return m ? (m[1] as Verdict) : null;
3808
+ }
3809
+
3810
+ /** A run's verdict: recorded when it finished, else read from its summary,
3811
+ * so runs from before the field existed show one too. */
3812
+ export function runVerdict(run: Pick<RunRecord, "verdict" | "summary">): Verdict | null {
3813
+ return run.verdict !== undefined ? run.verdict : verdictOf(run.summary);
3814
+ }
3815
+
3384
3816
  export function runSummary(run: RunRecord): string | null {
3385
3817
  for (let i = run.steps.length - 1; i >= 0; i--) {
3386
3818
  const line = stepHeadline(run.steps[i]);