@foldrun/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/browser-pod.d.ts +148 -0
  9. package/dist/src/browser-pod.js +333 -0
  10. package/dist/src/catalog.js +2 -1
  11. package/dist/src/clock.d.ts +49 -0
  12. package/dist/src/clock.js +121 -0
  13. package/dist/src/completions.js +17 -8
  14. package/dist/src/confine.d.ts +22 -0
  15. package/dist/src/confine.js +218 -3
  16. package/dist/src/container.d.ts +9 -0
  17. package/dist/src/container.js +115 -5
  18. package/dist/src/context-tools.d.ts +1 -1
  19. package/dist/src/deploy.d.ts +33 -0
  20. package/dist/src/deploy.js +145 -6
  21. package/dist/src/egress.d.ts +7 -0
  22. package/dist/src/egress.js +6 -0
  23. package/dist/src/eol.d.ts +2 -0
  24. package/dist/src/eol.js +7 -0
  25. package/dist/src/evals.d.ts +5 -0
  26. package/dist/src/evals.js +9 -2
  27. package/dist/src/flow-lint.d.ts +8 -0
  28. package/dist/src/flow-lint.js +223 -1
  29. package/dist/src/flow-patterns.d.ts +141 -0
  30. package/dist/src/flow-patterns.js +568 -0
  31. package/dist/src/gitrepo.d.ts +18 -0
  32. package/dist/src/gitrepo.js +49 -0
  33. package/dist/src/host-env.js +5 -0
  34. package/dist/src/kinds.js +1 -1
  35. package/dist/src/language.d.ts +17 -0
  36. package/dist/src/language.js +55 -0
  37. package/dist/src/layout.d.ts +69 -0
  38. package/dist/src/layout.js +200 -0
  39. package/dist/src/library.d.ts +9 -0
  40. package/dist/src/library.js +30 -3
  41. package/dist/src/limits.d.ts +84 -0
  42. package/dist/src/limits.js +287 -0
  43. package/dist/src/locale.d.ts +47 -0
  44. package/dist/src/locale.js +177 -0
  45. package/dist/src/mail.d.ts +88 -0
  46. package/dist/src/mail.js +247 -0
  47. package/dist/src/model-credential.d.ts +14 -0
  48. package/dist/src/model-credential.js +58 -0
  49. package/dist/src/model-probe.d.ts +3 -0
  50. package/dist/src/model-probe.js +1 -0
  51. package/dist/src/notify.d.ts +53 -7
  52. package/dist/src/notify.js +194 -126
  53. package/dist/src/openapi.js +5 -3
  54. package/dist/src/operator.d.ts +201 -0
  55. package/dist/src/operator.js +251 -0
  56. package/dist/src/paths.d.ts +28 -0
  57. package/dist/src/paths.js +55 -0
  58. package/dist/src/platform.d.ts +43 -3
  59. package/dist/src/platform.js +9 -1
  60. package/dist/src/provider-health.js +3 -2
  61. package/dist/src/providers.d.ts +481 -0
  62. package/dist/src/providers.js +1105 -6
  63. package/dist/src/refusal.d.ts +83 -0
  64. package/dist/src/refusal.js +281 -0
  65. package/dist/src/run-container.d.ts +149 -13
  66. package/dist/src/run-container.js +424 -43
  67. package/dist/src/runner.d.ts +177 -9
  68. package/dist/src/runner.js +1146 -154
  69. package/dist/src/runtime.d.ts +52 -0
  70. package/dist/src/runtime.js +493 -58
  71. package/dist/src/script-tools.d.ts +17 -2
  72. package/dist/src/script-tools.js +82 -10
  73. package/dist/src/secret-files.d.ts +6 -5
  74. package/dist/src/secret-files.js +24 -11
  75. package/dist/src/secret-health.d.ts +30 -6
  76. package/dist/src/secret-health.js +42 -20
  77. package/dist/src/secrets.d.ts +19 -0
  78. package/dist/src/secrets.js +155 -86
  79. package/dist/src/starter.d.ts +9 -0
  80. package/dist/src/starter.js +92 -11
  81. package/dist/src/step-exec.d.ts +72 -2
  82. package/dist/src/step-exec.js +431 -37
  83. package/dist/src/step-removal.d.ts +15 -0
  84. package/dist/src/step-removal.js +49 -0
  85. package/dist/src/storage.d.ts +23 -4
  86. package/dist/src/storage.js +125 -23
  87. package/dist/src/store.d.ts +222 -37
  88. package/dist/src/store.js +349 -62
  89. package/dist/src/subagents.d.ts +75 -0
  90. package/dist/src/subagents.js +149 -0
  91. package/dist/src/test-mode.d.ts +17 -2
  92. package/dist/src/test-mode.js +53 -13
  93. package/dist/src/tool-names.d.ts +13 -1
  94. package/dist/src/tool-names.js +38 -11
  95. package/dist/src/tool-programs.d.ts +26 -0
  96. package/dist/src/tool-programs.js +172 -0
  97. package/dist/src/tool-test.js +104 -12
  98. package/dist/src/translator.d.ts +8 -2
  99. package/dist/src/translator.js +69 -23
  100. package/dist/src/trigger-log.d.ts +1 -1
  101. package/dist/src/triggers.d.ts +3 -0
  102. package/dist/src/triggers.js +10 -2
  103. package/dist/src/untrusted-fetch.d.ts +11 -0
  104. package/dist/src/untrusted-fetch.js +77 -0
  105. package/dist/src/web-actions.d.ts +91 -0
  106. package/dist/src/web-actions.js +199 -0
  107. package/dist/src/webhook.d.ts +4 -0
  108. package/dist/src/webhook.js +39 -0
  109. package/index.ts +4 -0
  110. package/package.json +3 -3
  111. package/src/account.ts +149 -0
  112. package/src/api-tools.ts +32 -4
  113. package/src/browser-pod.ts +350 -0
  114. package/src/catalog.ts +2 -1
  115. package/src/clock.ts +149 -0
  116. package/src/completions.ts +17 -8
  117. package/src/confine.ts +218 -3
  118. package/src/container.ts +112 -5
  119. package/src/context-tools.ts +1 -1
  120. package/src/deploy.ts +137 -6
  121. package/src/egress.ts +13 -1
  122. package/src/eol.ts +7 -0
  123. package/src/evals.ts +14 -2
  124. package/src/flow-lint.ts +253 -1
  125. package/src/flow-patterns.ts +597 -0
  126. package/src/gitrepo.ts +48 -0
  127. package/src/host-env.ts +5 -0
  128. package/src/kinds.ts +1 -1
  129. package/src/language.ts +62 -0
  130. package/src/layout.ts +228 -0
  131. package/src/library.ts +37 -3
  132. package/src/limits.ts +290 -0
  133. package/src/locale.ts +217 -0
  134. package/src/mail.ts +318 -0
  135. package/src/model-credential.ts +71 -0
  136. package/src/model-probe.ts +4 -0
  137. package/src/notify.ts +189 -103
  138. package/src/openapi.ts +5 -3
  139. package/src/operator.ts +300 -0
  140. package/src/paths.ts +54 -0
  141. package/src/platform.ts +52 -3
  142. package/src/provider-health.ts +3 -2
  143. package/src/providers.ts +1368 -6
  144. package/src/refusal.ts +320 -0
  145. package/src/run-container.ts +460 -44
  146. package/src/runner.ts +1168 -155
  147. package/src/runtime.ts +487 -55
  148. package/src/script-tools.ts +81 -10
  149. package/src/secret-files.ts +22 -10
  150. package/src/secret-health.ts +64 -19
  151. package/src/secrets.ts +149 -87
  152. package/src/starter.ts +93 -11
  153. package/src/step-exec.ts +447 -38
  154. package/src/step-removal.ts +59 -0
  155. package/src/storage.ts +141 -22
  156. package/src/store.ts +506 -70
  157. package/src/subagents.ts +189 -0
  158. package/src/test-mode.ts +51 -11
  159. package/src/tool-names.ts +40 -11
  160. package/src/tool-programs.ts +179 -0
  161. package/src/tool-test.ts +110 -12
  162. package/src/translator.ts +72 -22
  163. package/src/trigger-log.ts +1 -0
  164. package/src/triggers.ts +10 -2
  165. package/src/untrusted-fetch.ts +88 -0
  166. package/src/web-actions.ts +237 -0
  167. package/src/webhook.ts +41 -0
  168. package/templates/blog-desk/agents/writer/agent.md +1 -1
  169. package/templates/hello/agents/curator/agent.md +4 -4
  170. package/templates/hello/agents/notetaker/agent.md +3 -3
package/dist/src/store.js CHANGED
@@ -22,8 +22,9 @@
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { recordRevision, registerTreeReader } from "./history.js";
25
- import { dataRoot, singleWorkspace } from "./paths.js";
25
+ import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.js";
26
26
  import { platform } from "./platform.js";
27
+ import { toLF, asRaw } from "./eol.js";
27
28
  import matter from "gray-matter";
28
29
  /**
29
30
  * The frontmatter of one markdown file, as the runtime reads it. Exported
@@ -37,14 +38,20 @@ import matter from "gray-matter";
37
38
  export function readFrontmatter(file) {
38
39
  return matter(fs.readFileSync(file, "utf8")).data;
39
40
  }
40
- import { ownToolNames, legacyUseNames } from "./tool-names.js";
41
+ import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.js";
41
42
  import { refNames } from "./refs.js";
42
43
  import { parseBudget, budgetProblem } from "./budget.js";
44
+ import { timezoneProblem } from "./clock.js";
45
+ import { parseStepLimits, readLimits } from "./limits.js";
46
+ import { webProblems, webWarnings, runnerEngines } from "./providers.js";
47
+ import { actionProblems } from "./web-actions.js";
48
+ import { languageProblem } from "./language.js";
49
+ import { localeProblems } from "./locale.js";
43
50
  import { readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles, } from "./okf.js";
44
51
  import { readTransport, KINDS } from "./kinds.js";
45
52
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS } from "./providers.js";
46
53
  import { starterFiles, accountFiles } from "./starter.js";
47
- import { trimSlashes } from "./paths.js";
54
+ import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.js";
48
55
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
49
56
  // against one folder, which is what `foldrun run ./my-desk` has to mean.
50
57
  /**
@@ -177,14 +184,15 @@ export function workspaceDir(tenant, workspace) {
177
184
  * The account root — where `library/`, account secrets and the account-wide
178
185
  * AGENTS.md live.
179
186
  *
180
- * On a laptop there is no account, so it is the workspace's parent: `my-desk/`
181
- * sits beside `library/` and an `AGENTS.md` covering everything there. Same
182
- * rule libraryDir uses, so the two never disagree about where "up" is.
187
+ * On a laptop it is wherever `singleAccountRoot` says: the workspace's parent
188
+ * for a flat `my-desk/`, and the account root two levels up for a workspace
189
+ * inside `my-account/workspaces/`. Same rule libraryDir uses, so the two never
190
+ * disagree about where "up" is.
183
191
  */
184
192
  export function accountDir(tenant) {
185
193
  const single = singleWorkspace();
186
194
  if (single)
187
- return path.resolve(single, "..");
195
+ return singleAccountRoot();
188
196
  assertSafeName(tenant, "tenant");
189
197
  return path.join(dataRoot(), tenant);
190
198
  }
@@ -277,14 +285,37 @@ export function assertCanonicalCase(rel) {
277
285
  * next deploy or silently handed to every container — both invisible until
278
286
  * someone is bitten.
279
287
  */
280
- export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json"];
288
+ // trigger-log.jsonl: the scheduler's record of why a flow did or did not
289
+ // fire (trigger-log.ts). Before it was listed here every deploy erased it.
290
+ export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
281
291
  /** True for anything the platform, not the author or the agent, writes. */
282
292
  export function isPlatformPath(rel) {
283
293
  const norm = rel.replaceAll("\\", "/");
284
294
  if (PLATFORM_FILES.includes(norm))
285
295
  return true;
296
+ // The vault's lock and in-flight temp files (secrets.ts#mutateVaultFile)
297
+ // are the vault, as far as anything outside the platform is concerned.
298
+ if (/(^|\/)secrets\.json\./.test(norm))
299
+ return true;
300
+ if (norm === "trigger-log.jsonl.tmp")
301
+ return true;
286
302
  return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
287
303
  }
304
+ /** What a deploy keeps whatever it ships — the rule above saveWorkspace's
305
+ * body. planDeploy reports removals through the same test, so a plan never
306
+ * lists a file the save would keep. */
307
+ export function keptOnDeploy(rel, shipped) {
308
+ return (
309
+ // Platform bookkeeping (the vault, hook rotation state, the delivery
310
+ // log, the trigger log, run history) — losing any of it on deploy
311
+ // silently breaks something: agents lose secrets, a rotated hook
312
+ // un-rotates, the record of why a schedule skipped is gone.
313
+ isPlatformPath(rel) ||
314
+ /(^|\/)state\//.test(rel) ||
315
+ rel === STORAGE_DIR ||
316
+ rel.startsWith(`${STORAGE_DIR}/`) ||
317
+ (/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel)));
318
+ }
288
319
  export function saveWorkspace(tenant, workspace, files,
289
320
  /** Attribution for the revision this save records. A deploy passes the
290
321
  * commit, which becomes the revision's id. */
@@ -336,15 +367,7 @@ meta = {}) {
336
367
  // about it. Before this, every deploy silently erased everything the agents
337
368
  // had learned.
338
369
  const shipped = new Set(files.map((f) => path.normalize(f.path)));
339
- const isAgentOwned = (rel) =>
340
- // Platform bookkeeping (the vault, hook rotation state, the delivery
341
- // log, run history) — losing any of it on deploy silently breaks
342
- // something: agents lose secrets, a rotated hook un-rotates.
343
- isPlatformPath(rel) ||
344
- /(^|\/)state\//.test(rel) ||
345
- rel === STORAGE_DIR ||
346
- rel.startsWith(`${STORAGE_DIR}/`) ||
347
- (/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel));
370
+ const isAgentOwned = (rel) => keptOnDeploy(rel, shipped);
348
371
  // What is there now, for the revision: every editable file's content
349
372
  // before this save replaces the tree.
350
373
  const before = new Map();
@@ -361,7 +384,7 @@ meta = {}) {
361
384
  const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
362
385
  const preserved = [];
363
386
  if (snapshot) {
364
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
387
+ for (const entry of entriesNoFollow(dir)) {
365
388
  const rel = String(entry).split(path.sep).join("/");
366
389
  const from = path.join(dir, rel);
367
390
  try {
@@ -460,7 +483,7 @@ export function buildMemoryIndex(dir, prefix = "") {
460
483
  // Workspace-scoped shared assets: skills/, memory/, tools/ sit beside agents/
461
484
  // and are available to every agent in that workspace.
462
485
  export function workspaceMemoryIndex(tenant, workspace) {
463
- return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "../../memory/");
486
+ return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "workspace/memory/");
464
487
  }
465
488
  // Knowledge is what you *gave* an agent — stable reference material an agent
466
489
  // reads and never rewrites. Memory is what it *learned* and authors itself.
@@ -470,7 +493,7 @@ export function knowledgeIndex(dir, prefix = "") {
470
493
  return buildMemoryIndex(dir, prefix);
471
494
  }
472
495
  export function workspaceKnowledgeIndex(tenant, workspace) {
473
- return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "../../knowledge/");
496
+ return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "workspace/knowledge/");
474
497
  }
475
498
  // One noun for capability. A tools/<name>.md file declares either an HTTP
476
499
  // endpoint or a script — `type:` says which, and it's inferred when omitted
@@ -515,6 +538,15 @@ export function fencedCodeBlock(body) {
515
538
  }
516
539
  return null;
517
540
  }
541
+ /**
542
+ * `outward: true` — this tool changes something that is not ours: it sends,
543
+ * posts, buys, publishes, uploads. The runtime does not treat it differently;
544
+ * `check` does, because the step that uses one is the step where a silent
545
+ * failure costs something that cannot be taken back. See `lintFlow`.
546
+ */
547
+ export function toolIsOutward(data) {
548
+ return data.outward === true;
549
+ }
518
550
  export function parseToolDef(data, fallbackName, body) {
519
551
  const name = typeof data.name === "string" ? data.name : fallbackName;
520
552
  // `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
@@ -582,6 +614,81 @@ export function parseToolDef(data, fallbackName, body) {
582
614
  *
583
615
  * Flat files keep working unchanged — the shape is a migration, not a break.
584
616
  */
617
+ /**
618
+ * A folder tool's `requirements.txt` and `package.json`, as a `runtime:`
619
+ * block. Requirement lines only: comments and blanks are left out, and so
620
+ * are options — whole option lines (`-r`, `--index-url`, `-e`) and the ones
621
+ * a requirement carries (`--hash=…`). Options never reach the installer,
622
+ * which takes them from the same argv as its operands; their NAMES are kept
623
+ * in `ignored`, never their values (an index URL can carry a password), so
624
+ * the build log can say what was not applied. Anything else that is not a
625
+ * requirement is refused later by parseRuntime with a log line, the same as
626
+ * it would be in tool.md. `dependencies` only — a tool's dev dependencies
627
+ * are its author's, not its runtime's.
628
+ */
629
+ export function folderRuntime(folder) {
630
+ const out = {};
631
+ const req = path.join(folder, "requirements.txt");
632
+ if (fs.existsSync(req)) {
633
+ const packages = [];
634
+ const ignored = new Set();
635
+ // Logical lines: a trailing backslash continues onto the next, which is
636
+ // how pip-compile --generate-hashes writes every pin. Read line by line,
637
+ // `pandas==2.1 \` became `pandas==2.1\`, was refused as invalid, and
638
+ // its hash lines were dropped as options: a hash-pinned file installed
639
+ // nothing at all.
640
+ const logical = fs.readFileSync(req, "utf8").replace(/\\\r?\n/g, " ").split(/\r?\n/);
641
+ for (const raw of logical) {
642
+ const line = raw.replace(/(^|\s)#.*$/, "").trim();
643
+ if (!line)
644
+ continue;
645
+ // The requirement is what comes before its first option.
646
+ const [head, ...opts] = line.split(/\s+(?=-)/);
647
+ for (const o of line.startsWith("-") ? [line, ...opts] : opts)
648
+ ignored.add(o.split(/[\s=]/)[0]);
649
+ if (line.startsWith("-"))
650
+ continue;
651
+ const requirement = head.replace(/\s+/g, "");
652
+ if (requirement)
653
+ packages.push(requirement);
654
+ }
655
+ if (packages.length)
656
+ out.packages = packages;
657
+ if (ignored.size)
658
+ out.ignored = [...ignored];
659
+ }
660
+ const pkg = path.join(folder, "package.json");
661
+ if (fs.existsSync(pkg)) {
662
+ try {
663
+ const deps = JSON.parse(fs.readFileSync(pkg, "utf8")).dependencies ?? {};
664
+ const npm = Object.entries(deps).map(([name, range]) => (range && range !== "*" && range !== "latest" ? `${name}@${range}` : name));
665
+ if (npm.length) {
666
+ out.npm = npm;
667
+ out.node = true;
668
+ }
669
+ }
670
+ catch {
671
+ // An unreadable package.json declares nothing; tool.md still can.
672
+ }
673
+ }
674
+ return out.packages || out.npm || out.ignored ? out : null;
675
+ }
676
+ /** tool.md's own `runtime:` plus what the folder's files add. */
677
+ function mergeRuntimeBlocks(own, extra) {
678
+ const base = own && typeof own === "object" && !Array.isArray(own) ? { ...own } : {};
679
+ const list = (v) => (Array.isArray(v) ? v.map(String) : []);
680
+ const packages = [...new Set([...list(base.packages ?? base.pip), ...list(extra.packages)])];
681
+ const npm = [...new Set([...list(base.npm), ...list(extra.npm)])];
682
+ const ignored = [...new Set([...list(base.ignored), ...list(extra.ignored)])];
683
+ delete base.pip;
684
+ return {
685
+ ...base,
686
+ ...(packages.length ? { packages } : {}),
687
+ ...(npm.length ? { npm } : {}),
688
+ ...(ignored.length ? { ignored } : {}),
689
+ ...(extra.node && base.node === undefined ? { node: true } : {}),
690
+ };
691
+ }
585
692
  function readToolDir(dir, scope = "workspace") {
586
693
  if (!fs.existsSync(dir))
587
694
  return {};
@@ -608,9 +715,19 @@ function readToolDir(dir, scope = "workspace") {
608
715
  if (folder && typeof d.run === "string" && !/^(workspace|account|shared|library)\//.test(d.run)) {
609
716
  d.run = `${scope}/tools/${folder}/${d.run.replace(/^\.\//, "")}`;
610
717
  }
718
+ // The ecosystem's own dependency files, beside the code that needs them,
719
+ // count as `runtime:` — a tool copied out of a repo keeps working.
720
+ if (folder) {
721
+ const extra = folderRuntime(path.join(dir, folder));
722
+ if (extra)
723
+ d.runtime = mergeRuntimeBlocks(d.runtime, extra);
724
+ }
611
725
  const def = parseToolDef(d, fallbackName, content);
726
+ // `outward: true` rides on the definition so a caller that resolved the
727
+ // grants — `check` — can ask which agents can act outside the workspace
728
+ // without reading every tool file a second time.
612
729
  if (def)
613
- out[def.name] = def;
730
+ out[def.name] = toolIsOutward(d) ? { ...def, outward: true } : def;
614
731
  else
615
732
  broken.push(`${fallbackName}: no base, run, command or fenced code block — nothing to call`);
616
733
  }
@@ -890,12 +1007,40 @@ export function agentAssets(tenant, workspace, agent) {
890
1007
  }
891
1008
  return { skills, memory };
892
1009
  }
1010
+ /** `schedule:` on an agent runs nothing — only a flow has a clock. Named so
1011
+ * it fails loudly rather than deploying clean and never firing. */
1012
+ function agentScheduleProblem(data) {
1013
+ return data.schedule !== undefined
1014
+ ? "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."
1015
+ : null;
1016
+ }
893
1017
  /** A per-run `budget:` as a number, or null. A period other than "run"
894
1018
  * written here is a mistake the lint names; it is not silently a cap. */
895
1019
  function runBudget(raw) {
896
1020
  const b = parseBudget(raw, "run");
897
1021
  return b && b.period === "run" ? b.usd : null;
898
1022
  }
1023
+ /** The names an agent's inline tool fields grant: `apis:`, `scripts:` (an
1024
+ * object's name, or a file's base name) and `mcpServers:`. */
1025
+ function inlineToolNames(data) {
1026
+ const out = [];
1027
+ if (Array.isArray(data.apis)) {
1028
+ for (const a of data.apis)
1029
+ if (a && typeof a === "object" && typeof a.name === "string")
1030
+ out.push(a.name);
1031
+ }
1032
+ if (Array.isArray(data.scripts)) {
1033
+ for (const sc of data.scripts) {
1034
+ if (typeof sc === "string")
1035
+ out.push(path.basename(sc).replace(/\.[^.]+$/, ""));
1036
+ else if (sc && typeof sc === "object" && typeof sc.name === "string")
1037
+ out.push(sc.name);
1038
+ }
1039
+ }
1040
+ if (data.mcpServers && typeof data.mcpServers === "object")
1041
+ out.push(...Object.keys(data.mcpServers));
1042
+ return out;
1043
+ }
899
1044
  export function listAgents(tenant, workspace) {
900
1045
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
901
1046
  if (!fs.existsSync(dir))
@@ -916,11 +1061,22 @@ export function listAgents(tenant, workspace) {
916
1061
  apis: parseApis(data.apis),
917
1062
  ownTools: ownToolNames(data),
918
1063
  legacyUse: legacyUseNames(data),
1064
+ retiredTools: retiredToolNames(data),
919
1065
  consults: refNames(data.agents),
1066
+ subagents: refNames(data.subagents),
920
1067
  skills: data.skills === undefined ? null : refNames(data.skills),
921
1068
  secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
922
1069
  budget: runBudget(data.budget),
923
1070
  budgetProblem: budgetProblem(data.budget, ["run"]),
1071
+ timezone: typeof data.timezone === "string" ? data.timezone : null,
1072
+ timezoneProblem: timezoneProblem(data.timezone),
1073
+ languageProblem: languageProblem(data.language),
1074
+ localeProblems: localeProblems(data),
1075
+ webProblems: [...webProblems(data), ...actionProblems(data)],
1076
+ webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
1077
+ scheduleProblem: agentScheduleProblem(data),
1078
+ ...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
1079
+ inlineTools: inlineToolNames(data),
924
1080
  };
925
1081
  });
926
1082
  }
@@ -941,9 +1097,14 @@ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
941
1097
  * swallowed as an unknown option, silently, along with any typo of a real
942
1098
  * key. The consistency suite reads this list against the docs. */
943
1099
  const STEP_OPTION_KEYS = new Set([
944
- "approve", "ask", "case", "delegate", "each", "effort", "else", "loop", "max", "max_turns", "model",
1100
+ "approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
945
1101
  "on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
946
1102
  ]);
1103
+ /** A line that opens a markdown block rather than continuing a sentence:
1104
+ * a heading, a bullet, a numbered item, a quote, a rule, or a paragraph
1105
+ * that opens in bold. Tested against the line already trimmed, so it says
1106
+ * "this is a document" whatever margin the list gave it. */
1107
+ const MARKDOWN_BLOCK_RE = /^(?:#{1,6}\s|[-*+]\s|>\s|\d+[.)]\s|\*\*|__|---|\|)/;
947
1108
  /** The most fan-out instances `parallel:` may run at once — the fan-out's
948
1109
  * own hard cap, since more slots than items is no cap. */
949
1110
  export const PARALLEL_CAP = 20;
@@ -1002,7 +1163,7 @@ export function parseInstant(raw) {
1002
1163
  * removed a leading OR a trailing quote independently, so a value that
1003
1164
  * merely *ended* with one lost it — and
1004
1165
  *
1005
- * verify: test "$(cat ../../storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
1166
+ * verify: test "$(cat workspace/storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
1006
1167
  *
1007
1168
  * reached bash unbalanced, which exited 2 with "unexpected EOF" on every
1008
1169
  * run. The step could never pass, whatever the agent did, and nothing said
@@ -1043,6 +1204,16 @@ export function unquote(raw) {
1043
1204
  * The marker must also END at a boundary, so BLOCKED does not match
1044
1205
  * BLOCKEDBY.
1045
1206
  */
1207
+ /**
1208
+ * The path of a `when: rows of <path>` condition, or null when the condition
1209
+ * is a marker. `rows of` is the phrase `each:` uses for the same file, so a
1210
+ * flow reads a CSV one way in both. The whole phrase is required: a bare
1211
+ * `when: ROWS` stays the marker it always was.
1212
+ */
1213
+ export function whenRowsPath(when) {
1214
+ const m = when.trim().match(/^rows\s+of\s+(\S.*)$/i);
1215
+ return m ? m[1].trim() : null;
1216
+ }
1046
1217
  export function markerPresent(text, marker) {
1047
1218
  const needle = marker.trim().toLowerCase();
1048
1219
  if (!needle || !text)
@@ -1064,8 +1235,15 @@ export function markerPresent(text, marker) {
1064
1235
  return false;
1065
1236
  }
1066
1237
  export function parseFlow(file, raw) {
1238
+ // A flow saved with CRLF (Windows, some editors) read as no steps at all:
1239
+ // every line kept its "\r", which STEP_RE's `.` will not match. Read it as
1240
+ // LF; the line count, and so every step's `line`, is the same.
1241
+ raw = raw.replace(/\r\n/g, "\n");
1067
1242
  const { data, content } = matter(raw);
1068
1243
  const steps = [];
1244
+ // Flow-level problems: a malformed step target (below) and unreadable
1245
+ // frontmatter enums (before the return). Reported as errors by flow-lint.
1246
+ const frontProblems = [];
1069
1247
  // Frontmatter is stripped by matter(), so add it back to keep line numbers
1070
1248
  // pointing at the real file rather than the body.
1071
1249
  const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
@@ -1075,6 +1253,9 @@ export function parseFlow(file, raw) {
1075
1253
  // shape to be written out rather than named, because a schema is a
1076
1254
  // document and a one-line one is unreadable past three fields.
1077
1255
  let block = null;
1256
+ // The step still accepting wrapped instruction lines, or null once
1257
+ // anything has closed it. See the continuation rule below.
1258
+ let open = null;
1078
1259
  const endBlock = () => {
1079
1260
  if (!block)
1080
1261
  return;
@@ -1119,26 +1300,52 @@ export function parseFlow(file, raw) {
1119
1300
  instruction: m[5].trim(),
1120
1301
  line: lineNo + offset,
1121
1302
  });
1303
+ open = steps[steps.length - 1];
1122
1304
  continue;
1123
1305
  }
1124
- // An indented line that is not an option continues the instruction.
1306
+ // A line shaped like a numbered step whose `[[target]]` STEP_RE would
1307
+ // not accept — capitals or an underscore in the name — used to fall
1308
+ // through and be swallowed as prose, so the step silently vanished.
1309
+ // Name it instead: an agent is [a-z0-9-], and a real one that is merely
1310
+ // missing is a separate, later error.
1311
+ const shape = line.match(/^\s*\d+[?!]?\.?\s+\[\[(flow:)?([^\]]*)\]\]/);
1312
+ if (shape && !STEP_RE.test(line)) {
1313
+ 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]}\`.`);
1314
+ }
1315
+ // An indented line that is not an option continues the instruction —
1316
+ // but only while the step is still open.
1317
+ //
1318
+ // Both directions of this have been wrong in production. Dropping the
1319
+ // continuation silently cut twenty-five live instructions off mid
1320
+ // sentence. Taking every indented line instead read the *wrapped lines
1321
+ // of a bullet list* three paragraphs below the last step as orders: the
1322
+ // gbp-desk publisher was told to "publish Tuesday's five every way step
1323
+ // 3 could fail was also a way Thursday's five silently never went out".
1125
1324
  //
1126
- // Without this it was silently DROPPED. An instruction wrapped over
1127
- // three lines kept the first and discarded the rest, the run went
1128
- // green, and the agent worked from an instruction its author had not
1129
- // written — the worst failure this parser can have, because nothing
1130
- // anywhere reports it. Twenty-five live instructions across eight desks
1131
- // were being cut this way.
1325
+ // So a step's instruction ends where the step ends. A continuation is
1326
+ // an indented line that follows the step line, or a previous
1327
+ // continuation, with nothing in between — no blank line, no unindented
1328
+ // line, no option, no schema block. Once any of those closes the step
1329
+ // the instruction is finished, and prose below the last step is prose.
1132
1330
  //
1133
- // Indentation is what makes it a continuation, exactly as it is for an
1134
- // option. Unindented prose between steps is still prose and still
1135
- // ignored, so a flow file's commentary is undisturbed.
1331
+ // Indented markdown never continues an instruction either. A heading, a
1332
+ // bullet, a quote, a numbered item or a paragraph opening in bold is a
1333
+ // document, not a sentence somebody wrapped, and the indentation is a
1334
+ // list's own margin.
1136
1335
  const opt = line.match(OPTION_RE);
1137
- if (steps.length && /^\s+\S/.test(line) && !(opt && STEP_OPTION_KEYS.has(opt[2]))) {
1138
- const step = steps[steps.length - 1];
1139
- step.instruction = `${step.instruction} ${line.trim()}`.trim();
1140
- continue;
1336
+ const isOption = !!opt && STEP_OPTION_KEYS.has(opt[2]);
1337
+ if (open && /^\s+\S/.test(line) && !isOption) {
1338
+ if (MARKDOWN_BLOCK_RE.test(line.trim())) {
1339
+ open = null;
1340
+ }
1341
+ else {
1342
+ open.instruction = `${open.instruction} ${line.trim()}`.trim();
1343
+ open.wrapped = true;
1344
+ continue;
1345
+ }
1141
1346
  }
1347
+ // Anything that is not a continuation closes the step to further ones.
1348
+ open = null;
1142
1349
  // Indented options belong to the step above them.
1143
1350
  if (opt && steps.length) {
1144
1351
  const step = steps[steps.length - 1];
@@ -1197,10 +1404,10 @@ export function parseFlow(file, raw) {
1197
1404
  step.each = "items";
1198
1405
  else if (/^rows\b/.test(value)) {
1199
1406
  step.each = "rows";
1200
- // "rows of ../../storage/leads.csv" — the path is the part after "of".
1407
+ // "rows of workspace/storage/leads.csv" — the path is the part after "of".
1201
1408
  step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
1202
1409
  if (!step.eachPath)
1203
- problem("rows needs a file: each: rows of ../../storage/leads.csv");
1410
+ problem("rows needs a file: each: rows of workspace/storage/leads.csv");
1204
1411
  }
1205
1412
  else
1206
1413
  problem("lines, items, or rows of <path>");
@@ -1220,7 +1427,7 @@ export function parseFlow(file, raw) {
1220
1427
  step.ask = value;
1221
1428
  }
1222
1429
  else if (key === "preview") {
1223
- step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/)?storage\//, "")).filter(Boolean);
1430
+ step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/|workspace\/)?storage\//, "")).filter(Boolean);
1224
1431
  }
1225
1432
  else if (key === "delegate")
1226
1433
  step.delegate = refNames(value).slice(0, 5);
@@ -1244,6 +1451,13 @@ export function parseFlow(file, raw) {
1244
1451
  else
1245
1452
  step.maxTurns = n;
1246
1453
  }
1454
+ else if (key === "limits") {
1455
+ const read = parseStepLimits(value);
1456
+ for (const p of read.problems)
1457
+ problem(p);
1458
+ if (Object.keys(read.limits).length)
1459
+ step.limits = read.limits;
1460
+ }
1247
1461
  else if (key === "schema") {
1248
1462
  if (!value)
1249
1463
  block = { step, indent: indentText.length, lines: [] };
@@ -1262,6 +1476,20 @@ export function parseFlow(file, raw) {
1262
1476
  }
1263
1477
  }
1264
1478
  endBlock();
1479
+ // A frontmatter enum written with a typo — `overlap: que` — read as null
1480
+ // and ran with the silent default. flows.md promises otherwise ("a value
1481
+ // the option cannot read is a check error, never a guess"); make it true
1482
+ // for these too, not only for step options.
1483
+ const badEnum = (key, val, allowed) => {
1484
+ if (typeof val === "string" && val.trim() && !allowed.includes(val.trim())) {
1485
+ frontProblems.push(`${key}: ${val} — the values are ${allowed.join(", ")}.`);
1486
+ }
1487
+ };
1488
+ badEnum("overlap", data.overlap, ["skip", "queue"]);
1489
+ badEnum("priority", data.priority, ["high", "normal", "low"]);
1490
+ badEnum("catchup", data.catchup, ["none", "last"]);
1491
+ badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
1492
+ badEnum("signature", data.signature, ["github", "stripe", "slack", "hmac"]);
1265
1493
  steps.sort((a, b) => a.group - b.group);
1266
1494
  return {
1267
1495
  name: data.name ?? file.replace(/\.md$/, ""),
@@ -1279,7 +1507,7 @@ export function parseFlow(file, raw) {
1279
1507
  // `after: [[flow:publish]]` — a link or a bare name, read the same way
1280
1508
  // as every other file-naming field (refs.ts).
1281
1509
  after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
1282
- on: data.on === "failed" || data.on === "any" ? data.on : "completed",
1510
+ on: data.on === "failed" || data.on === "blocked" || data.on === "any" ? data.on : "completed",
1283
1511
  at: parseInstant(data.at),
1284
1512
  url: typeof data.url === "string" ? data.url.trim() : null,
1285
1513
  every: typeof data.every === "string" || typeof data.every === "number" ? (parseWait(String(data.every)) ?? null) : null,
@@ -1300,9 +1528,14 @@ export function parseFlow(file, raw) {
1300
1528
  disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
1301
1529
  ? Math.min(100, Math.floor(Number(data.disable_after)))
1302
1530
  : null,
1531
+ pauseWhen: typeof data.pause_when === "string" && data.pause_when.trim() && data.pause_when.trim().length <= 60
1532
+ ? data.pause_when.trim()
1533
+ : null,
1303
1534
  sla: durationOf(data.sla),
1304
1535
  approvers: approverList(data.approvers),
1305
1536
  approveWithin: durationOf(data.approve_within),
1537
+ description: typeof data.description === "string" ? data.description.trim() || null : null,
1538
+ frontProblems,
1306
1539
  steps,
1307
1540
  };
1308
1541
  }
@@ -1414,7 +1647,7 @@ function emitFlow(head, preamble, groups) {
1414
1647
  * and the diff is exactly the lines that changed. A flow with no
1415
1648
  * frontmatter gains the smallest one that can carry the trigger.
1416
1649
  */
1417
- export function setFlowTrigger(raw, opts) {
1650
+ function setFlowTriggerLF(raw, opts) {
1418
1651
  if (!FLOW_TRIGGERS.includes(opts.trigger)) {
1419
1652
  throw new Error(`unknown trigger "${opts.trigger}" — one of ${FLOW_TRIGGERS.join(", ")}`);
1420
1653
  }
@@ -1470,7 +1703,7 @@ export function setFlowTrigger(raw, opts) {
1470
1703
  * the prefix is taken verbatim from the file and only the tail replaced. A
1471
1704
  * flow's markdown stays the author's.
1472
1705
  */
1473
- export function updateFlowStepInstruction(raw, index, instruction) {
1706
+ function updateFlowStepInstructionLF(raw, index, instruction) {
1474
1707
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1475
1708
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
1476
1709
  throw new Error(`no step ${index}`);
@@ -1496,7 +1729,7 @@ export function updateFlowStepInstruction(raw, index, instruction) {
1496
1729
  }
1497
1730
  return emitFlow(head, preamble, groups);
1498
1731
  }
1499
- export function updateFlowStep(raw, index, options) {
1732
+ function updateFlowStepLF(raw, index, options) {
1500
1733
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1501
1734
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
1502
1735
  throw new Error(`no step ${index}`);
@@ -1514,6 +1747,17 @@ export function updateFlowStep(raw, index, options) {
1514
1747
  loop: (v) => (Number(v) > 0 ? String(Math.min(5, Math.max(1, Math.floor(Number(v))))) : null),
1515
1748
  max: (v) => (Number(v) > 0 ? String(Math.min(20, Math.max(1, Math.floor(Number(v))))) : null),
1516
1749
  each: (v) => (v === "lines" ? "lines" : null),
1750
+ // `{web.search: 10, calls: 300}` — one line, read the way parseFlow
1751
+ // reads it; a value it would flag is refused rather than written.
1752
+ limits: (v) => {
1753
+ if (!v || v === "{}")
1754
+ return null;
1755
+ const read = parseStepLimits(v);
1756
+ if (read.problems.length)
1757
+ throw new Error(`limits: ${read.problems[0]}`);
1758
+ const pairs = Object.entries(read.limits);
1759
+ return pairs.length ? `{${pairs.map(([k, n]) => `${k}: ${n}`).join(", ")}}` : null;
1760
+ },
1517
1761
  };
1518
1762
  const block = blocks[index];
1519
1763
  const managed = new Set(Object.keys(options));
@@ -1542,7 +1786,7 @@ export function updateFlowStep(raw, index, options) {
1542
1786
  }
1543
1787
  return emitFlow(head, preamble, groups);
1544
1788
  }
1545
- export function reorderFlowSteps(raw, groups) {
1789
+ function reorderFlowStepsLF(raw, groups) {
1546
1790
  const { head, preamble, blocks: ordered } = splitFlowBlocks(raw);
1547
1791
  const seen = new Set();
1548
1792
  for (const group of groups) {
@@ -1562,7 +1806,7 @@ export function reorderFlowSteps(raw, groups) {
1562
1806
  return emitFlow(head, preamble, groups.map((g) => g.map((i) => ordered[i])));
1563
1807
  }
1564
1808
  /** Append a step as its own group at the end of the flow. */
1565
- export function addFlowStep(raw, step) {
1809
+ function addFlowStepLF(raw, step) {
1566
1810
  assertSafeName(step.target, "step target");
1567
1811
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1568
1812
  const instruction = (step.instruction ?? "").replace(/[\r\n]+/g, " ").trim();
@@ -1574,7 +1818,7 @@ export function addFlowStep(raw, step) {
1574
1818
  return emitFlow(head, preamble, groups);
1575
1819
  }
1576
1820
  /** Drop one step, by its index in the flow's parsed steps. */
1577
- export function removeFlowStep(raw, index) {
1821
+ function removeFlowStepLF(raw, index) {
1578
1822
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1579
1823
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
1580
1824
  throw new Error(`no step ${index}`);
@@ -1582,6 +1826,14 @@ export function removeFlowStep(raw, index) {
1582
1826
  const kept = blocks.filter((_, i) => i !== index);
1583
1827
  return emitFlow(head, preamble, groupBlocks(kept));
1584
1828
  }
1829
+ // The rewriters above work on LF (eol.ts); the web route calls these with
1830
+ // the file as saved, so a CRLF flow is read as LF and written back as CRLF.
1831
+ export const setFlowTrigger = (raw, opts) => asRaw(raw, setFlowTriggerLF(toLF(raw), opts));
1832
+ export const updateFlowStepInstruction = (raw, index, instruction) => asRaw(raw, updateFlowStepInstructionLF(toLF(raw), index, instruction));
1833
+ export const updateFlowStep = (raw, index, options) => asRaw(raw, updateFlowStepLF(toLF(raw), index, options));
1834
+ export const reorderFlowSteps = (raw, groups) => asRaw(raw, reorderFlowStepsLF(toLF(raw), groups));
1835
+ export const addFlowStep = (raw, step) => asRaw(raw, addFlowStepLF(toLF(raw), step));
1836
+ export const removeFlowStep = (raw, index) => asRaw(raw, removeFlowStepLF(toLF(raw), index));
1585
1837
  /** Blocks → the group structure they currently describe. */
1586
1838
  function groupBlocks(blocks) {
1587
1839
  const map = new Map();
@@ -1748,17 +2000,29 @@ function assertEditablePath(rel) {
1748
2000
  * never be served. Listing them is honest — hiding them is what makes a file
1749
2001
  * tree disagree with the filesystem it claims to show — but their contents
1750
2002
  * are the vault and the money, so a reader gets the reason instead. */
1751
- const ACCOUNT_SEALED = new Set(["secrets.json", "oauth-clients.json", "ledger.jsonl"]);
2003
+ // billing.json, oauth-connections.json and secret-health.json were listed and
2004
+ // served until 2026-09-24: any member of the account, a viewer included, could
2005
+ // open the Stripe customer id and billing email, and which connections exist.
2006
+ const ACCOUNT_SEALED = {
2007
+ "secrets.json": "the vault — encrypted, and never served to a browser. Manage secrets in Settings.",
2008
+ "oauth-clients.json": "OAuth client credentials — never served to a browser.",
2009
+ "ledger.jsonl": "the billing ledger — append-only, read it through Usage and Wallet.",
2010
+ "billing.json": "the account's billing settings — read and change them in Wallet.",
2011
+ "oauth-connections.json": "connected apps and their health — see Settings → Connections.",
2012
+ "secret-health.json": "secret usage bookkeeping — see Settings → Secrets.",
2013
+ };
1752
2014
  export function accountFileSealed(rel) {
1753
2015
  const norm = rel.replaceAll("\\", "/");
1754
- if (ACCOUNT_SEALED.has(norm)) {
1755
- return norm === "secrets.json"
1756
- ? "the vault — encrypted, and never served to a browser. Manage secrets in Settings."
1757
- : norm === "oauth-clients.json"
1758
- ? "OAuth client credentials — never served to a browser."
1759
- : "the billing ledger — append-only, read it through Usage and Wallet.";
1760
- }
1761
- if (/^(billed|workspaces\/[^/]+\/runs)\//.test(norm)) {
2016
+ if (/(^|\/)secrets\.json\.[^/]+$/.test(norm))
2017
+ return "the vault's lock or a save in flight — never served.";
2018
+ if (Object.hasOwn(ACCOUNT_SEALED, norm))
2019
+ return ACCOUNT_SEALED[norm];
2020
+ // What a move into the database leaves behind: billing.json.imported-<date>
2021
+ // and the like, kept for a rollback — the same contents, so the same seal.
2022
+ const moved = /^(.+\.json)\.imported-[\d-]+$/.exec(norm);
2023
+ if (moved && Object.hasOwn(ACCOUNT_SEALED, moved[1]))
2024
+ return ACCOUNT_SEALED[moved[1]];
2025
+ if (/^(billed|once|topups|workspaces\/[^/]+\/runs)\//.test(norm)) {
1762
2026
  return "generated bookkeeping, not an authored file.";
1763
2027
  }
1764
2028
  if (/^workspaces\/[^/]+\/secrets\.json$/.test(norm)) {
@@ -1771,7 +2035,7 @@ export function accountFileSealed(rel) {
1771
2035
  * function is to refuse the person who clicked them, which is noise wearing
1772
2036
  * honesty's clothes. `accountFileSealed` still refuses these if a path is
1773
2037
  * constructed directly — the listing hides them, the reader guards them. */
1774
- const ACCOUNT_BOOKKEEPING = /^(billed\/|ledger\.jsonl$|oauth-clients\.json$)|(^|\/)runs\/|(^|\/)secrets\.json$/;
2038
+ const ACCOUNT_BOOKKEEPING = /^(billed\/|once\/|topups\/|ledger\.jsonl$|oauth-clients\.json$|billing\.json$|oauth-connections\.json$|secret-health\.json$|[^/]+\.json\.imported-[\d-]+$)|(^|\/)runs\/|(^|\/)secrets\.json(\.[^/]+)?$/;
1775
2039
  /**
1776
2040
  * The account directory as it actually is on disk.
1777
2041
  *
@@ -1849,7 +2113,7 @@ export function listWorkspaceFiles(tenant, workspace) {
1849
2113
  if (!fs.existsSync(dir))
1850
2114
  return [];
1851
2115
  const out = [];
1852
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
2116
+ for (const entry of entriesNoFollow(dir)) {
1853
2117
  const rel = String(entry);
1854
2118
  if (/(^|\/)(runs|outputs)\//.test(rel))
1855
2119
  continue;
@@ -1888,7 +2152,9 @@ export function readWorkspaceFile(tenant, workspace, rel) {
1888
2152
  return fs.readFileSync(p, "utf8");
1889
2153
  }
1890
2154
  export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
1891
- if (content.length > 256 * 1024)
2155
+ // 512 KB: a folder tool's program is one file, and the gallery browser's is
2156
+ // past 256 KB. Still a file a person edits, not a store for data.
2157
+ if (content.length > MAX_EDITABLE_FILE)
1892
2158
  throw new Error("file too large");
1893
2159
  matter(content); // reject files whose frontmatter doesn't parse
1894
2160
  const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
@@ -2067,9 +2333,11 @@ export function deleteWorkspacePath(tenant, workspace, rel, meta = {}) {
2067
2333
  }
2068
2334
  // Whole-folder units: an agent, a skill, a folder tool. Each is one thing
2069
2335
  // to its reader, so each is one thing to delete.
2070
- if (/^agents\/[a-z0-9-]+$/.test(norm) ||
2071
- /^(skills|tools)\/[a-z0-9-]+$/.test(norm) ||
2072
- /^agents\/[a-z0-9-]+\/skills\/[a-z0-9-]+$/.test(norm)) {
2336
+ // An underscore is allowed in the folder's name: a tool called
2337
+ // `my_tool` is one thing too, and its tool.md is refused alone above.
2338
+ if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
2339
+ /^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
2340
+ /^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
2073
2341
  const gone = deletionsUnder(dir, norm);
2074
2342
  fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
2075
2343
  recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
@@ -2639,6 +2907,25 @@ export function runFailure(run) {
2639
2907
  * runFailure, which puts the reason for a failure on the list for the same
2640
2908
  * reason — the sentence was in the record all along.
2641
2909
  */
2910
+ export const VERDICTS = ["GOOD", "BAD", "QUIET", "BLOCKED"];
2911
+ /**
2912
+ * The verdict a headline leads with, or null. Leading, whole-word and
2913
+ * upper-case only — "GOOD — 6 links applied", "**BLOCKED**: nothing
2914
+ * pushed", "BAD: 3 pages dropped". A summary that merely contains the word
2915
+ * ("no BLOCKED items") has no verdict, for the reason markerPresent gives.
2916
+ */
2917
+ export function verdictOf(summary) {
2918
+ if (!summary)
2919
+ return null;
2920
+ const line = summary.replace(/^[\s#>*_`\-]+/, "");
2921
+ const m = /^(GOOD|BAD|QUIET|BLOCKED)(?![A-Za-z0-9_])/.exec(line);
2922
+ return m ? m[1] : null;
2923
+ }
2924
+ /** A run's verdict: recorded when it finished, else read from its summary,
2925
+ * so runs from before the field existed show one too. */
2926
+ export function runVerdict(run) {
2927
+ return run.verdict !== undefined ? run.verdict : verdictOf(run.summary);
2928
+ }
2642
2929
  export function runSummary(run) {
2643
2930
  for (let i = run.steps.length - 1; i >= 0; i--) {
2644
2931
  const line = stepHeadline(run.steps[i]);