@foldrun/core 2.3.0 → 2.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 (135) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +1 -1
  4. package/dist/src/agent-tools.js +1 -1
  5. package/dist/src/agents-md.js +1 -1
  6. package/dist/src/alerts.d.ts +60 -0
  7. package/dist/src/alerts.js +161 -0
  8. package/dist/src/browser-pod.js +4 -0
  9. package/dist/src/capture.d.ts +23 -0
  10. package/dist/src/capture.js +80 -0
  11. package/dist/src/compare.d.ts +57 -0
  12. package/dist/src/compare.js +116 -0
  13. package/dist/src/completions.js +1 -0
  14. package/dist/src/deploy.d.ts +36 -1
  15. package/dist/src/deploy.js +170 -11
  16. package/dist/src/egress.d.ts +16 -1
  17. package/dist/src/evals.d.ts +5 -1
  18. package/dist/src/evals.js +26 -18
  19. package/dist/src/frontmatter-fields.js +9 -0
  20. package/dist/src/frontmatter-schema.js +3 -0
  21. package/dist/src/history.d.ts +4 -0
  22. package/dist/src/history.js +15 -0
  23. package/dist/src/hooks.js +1 -1
  24. package/dist/src/insights.d.ts +30 -0
  25. package/dist/src/insights.js +152 -0
  26. package/dist/src/issues.d.ts +140 -0
  27. package/dist/src/issues.js +302 -0
  28. package/dist/src/library.js +1 -1
  29. package/dist/src/limits.d.ts +8 -0
  30. package/dist/src/limits.js +36 -5
  31. package/dist/src/linkable.js +1 -1
  32. package/dist/src/matter.d.ts +8 -0
  33. package/dist/src/matter.js +28 -0
  34. package/dist/src/model-credential.d.ts +14 -0
  35. package/dist/src/model-credential.js +40 -0
  36. package/dist/src/model-env.d.ts +14 -2
  37. package/dist/src/model-env.js +34 -9
  38. package/dist/src/network.d.ts +12 -0
  39. package/dist/src/network.js +25 -1
  40. package/dist/src/notify.d.ts +16 -0
  41. package/dist/src/notify.js +17 -7
  42. package/dist/src/okf.js +1 -1
  43. package/dist/src/online-checks.d.ts +76 -0
  44. package/dist/src/online-checks.js +432 -0
  45. package/dist/src/operator.d.ts +277 -0
  46. package/dist/src/operator.js +176 -2
  47. package/dist/src/otel-config.d.ts +18 -0
  48. package/dist/src/otel-config.js +69 -0
  49. package/dist/src/otel.d.ts +38 -0
  50. package/dist/src/otel.js +85 -0
  51. package/dist/src/outcome.d.ts +40 -0
  52. package/dist/src/outcome.js +77 -0
  53. package/dist/src/package.js +1 -1
  54. package/dist/src/platform.d.ts +1 -1
  55. package/dist/src/prompt-layers.d.ts +30 -0
  56. package/dist/src/prompt-layers.js +77 -0
  57. package/dist/src/provider-health.js +1 -1
  58. package/dist/src/providers.d.ts +5 -0
  59. package/dist/src/providers.js +2 -2
  60. package/dist/src/run-container.d.ts +16 -2
  61. package/dist/src/run-container.js +57 -5
  62. package/dist/src/runner.d.ts +53 -2
  63. package/dist/src/runner.js +356 -70
  64. package/dist/src/runs-filter.d.ts +48 -0
  65. package/dist/src/runs-filter.js +73 -0
  66. package/dist/src/scan.d.ts +19 -1
  67. package/dist/src/scan.js +60 -7
  68. package/dist/src/starter.js +4 -2
  69. package/dist/src/step-exec.d.ts +29 -1
  70. package/dist/src/step-exec.js +106 -6
  71. package/dist/src/store.d.ts +130 -3
  72. package/dist/src/store.js +145 -6
  73. package/dist/src/tokens.d.ts +38 -0
  74. package/dist/src/tokens.js +57 -0
  75. package/dist/src/tool-names.js +1 -1
  76. package/dist/src/tool-programs.js +1 -1
  77. package/dist/src/trace-tree.d.ts +40 -0
  78. package/dist/src/trace-tree.js +156 -0
  79. package/dist/src/translator.d.ts +8 -0
  80. package/dist/src/translator.js +7 -2
  81. package/dist/src/trigger-log.d.ts +1 -1
  82. package/dist/src/workspace-check.d.ts +18 -0
  83. package/dist/src/workspace-check.js +34 -0
  84. package/package.json +2 -2
  85. package/src/agent-activity.ts +32 -0
  86. package/src/agent-import.ts +1 -1
  87. package/src/agent-tools.ts +1 -1
  88. package/src/agents-md.ts +1 -1
  89. package/src/alerts.ts +190 -0
  90. package/src/browser-pod.ts +3 -0
  91. package/src/capture.ts +88 -0
  92. package/src/compare.ts +146 -0
  93. package/src/completions.ts +1 -0
  94. package/src/deploy.ts +184 -10
  95. package/src/egress.ts +12 -2
  96. package/src/evals.ts +26 -18
  97. package/src/frontmatter-fields.ts +9 -0
  98. package/src/frontmatter-schema.ts +3 -0
  99. package/src/history.ts +13 -0
  100. package/src/hooks.ts +1 -1
  101. package/src/insights.ts +174 -0
  102. package/src/issues.ts +396 -0
  103. package/src/library.ts +1 -1
  104. package/src/limits.ts +38 -5
  105. package/src/linkable.ts +1 -1
  106. package/src/matter.ts +33 -0
  107. package/src/model-credential.ts +45 -0
  108. package/src/model-env.ts +42 -10
  109. package/src/network.ts +25 -1
  110. package/src/notify.ts +22 -7
  111. package/src/okf.ts +1 -1
  112. package/src/online-checks.ts +450 -0
  113. package/src/operator.ts +213 -3
  114. package/src/otel-config.ts +67 -0
  115. package/src/otel.ts +100 -0
  116. package/src/outcome.ts +90 -0
  117. package/src/package.ts +1 -1
  118. package/src/platform.ts +1 -1
  119. package/src/prompt-layers.ts +90 -0
  120. package/src/provider-health.ts +1 -1
  121. package/src/providers.ts +7 -2
  122. package/src/run-container.ts +66 -7
  123. package/src/runner.ts +363 -46
  124. package/src/runs-filter.ts +88 -0
  125. package/src/scan.ts +68 -7
  126. package/src/starter.ts +4 -2
  127. package/src/step-exec.ts +147 -9
  128. package/src/store.ts +254 -8
  129. package/src/tokens.ts +68 -0
  130. package/src/tool-names.ts +1 -1
  131. package/src/tool-programs.ts +1 -1
  132. package/src/trace-tree.ts +187 -0
  133. package/src/translator.ts +13 -2
  134. package/src/trigger-log.ts +1 -0
  135. package/src/workspace-check.ts +41 -0
package/src/limits.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // web: 150 # every web action together
7
7
  // crm: 20 # an API, script or MCP tool, by the name it is granted as
8
8
  // calls: 300 # every tool call in the step, whatever the tool
9
+ // repeat: 5 # the same call — one tool, the same input — at most this often
9
10
  //
10
11
  // Counted per step and enforced in step-exec.ts's PreToolUse hook — the one
11
12
  // place every call passes, the step's own and its sub-agents', on the
@@ -18,6 +19,7 @@
18
19
  // Nearest wins per key: the account's AGENTS.md, then the workspace's, then
19
20
  // the agent's own `limits:`, then a flow step's `limits:` option.
20
21
 
22
+ import crypto from "node:crypto";
21
23
  import { WEB_ACTIONS } from "./providers.ts";
22
24
  import { DELEGATE_TOOLS } from "./subagents.ts";
23
25
 
@@ -26,6 +28,23 @@ export type Limits = Record<string, number>;
26
28
  /** The key that caps every call in the step. */
27
29
  export const TOTAL_KEY = "calls";
28
30
 
31
+ /** The key that caps one identical call: a model asking the same tool the
32
+ * same thing over and over is stuck, and every repeat is spend. Each call
33
+ * also counts under `repeat:<hash of tool and input>`; `repeat: N` caps
34
+ * every one of those. */
35
+ export const REPEAT_KEY = "repeat";
36
+
37
+ /** JSON with its keys sorted, so `{a,b}` and `{b,a}` are the same call. */
38
+ function stable(v: unknown): string {
39
+ if (Array.isArray(v)) return `[${v.map(stable).join(",")}]`;
40
+ if (v && typeof v === "object") return `{${Object.keys(v as object).sort().map((k) => `${JSON.stringify(k)}:${stable((v as Record<string, unknown>)[k])}`).join(",")}}`;
41
+ return JSON.stringify(v) ?? "null";
42
+ }
43
+
44
+ function repeatKey(toolName: string, input: Record<string, unknown> | undefined): string {
45
+ return `${REPEAT_KEY}:${crypto.createHash("sha1").update(`${toolName}\0${stable(input ?? {})}`).digest("hex").slice(0, 16)}`;
46
+ }
47
+
29
48
  /** SDK built-ins, counted under the foldrun group that grants them — and
30
49
  * under their own SDK name, so `Bash: 5` works for a Claude Code file. */
31
50
  const BUILTIN_GROUP: Record<string, string> = {
@@ -43,6 +62,7 @@ const PLATFORM_SERVERS: Record<string, string> = {
43
62
  foldrun_history: "history",
44
63
  foldrun_desks: "desks",
45
64
  foldrun_ask: "ask",
65
+ foldrun_peers: "peers",
46
66
  };
47
67
 
48
68
  /** One spelling for comparing: `desk-email` and `desk_email` are the same
@@ -78,7 +98,7 @@ export function readLimits(raw: unknown, where = "limits"): { limits: Limits; pr
78
98
  for (const [key, value] of flat) {
79
99
  const k = key.trim();
80
100
  if (!KEY_RE.test(k)) {
81
- problems.push(`${where}: "${k}" is not a key — a tool's name, web.<action>, or calls.`);
101
+ problems.push(`${where}: "${k}" is not a key — a tool's name, web.<action>, calls, or repeat.`);
82
102
  continue;
83
103
  }
84
104
  if (k.includes(".")) {
@@ -166,6 +186,12 @@ export function limitKeysFor(toolName: string, input: Record<string, unknown> |
166
186
  return keys;
167
187
  }
168
188
 
189
+ /** What step-exec counts one call under: limitKeysFor's keys and the
190
+ * call's own `repeat:` key, for `limits: repeat`. */
191
+ export function callKeysFor(toolName: string, input: Record<string, unknown> | undefined, owners: Record<string, string> = {}): string[] {
192
+ return [...limitKeysFor(toolName, input, owners), repeatKey(toolName, input)];
193
+ }
194
+
169
195
  export interface LimitRefusal {
170
196
  key: string;
171
197
  used: number;
@@ -193,14 +219,17 @@ export class CallCounter {
193
219
  take(keys: string[]): LimitRefusal | null {
194
220
  const norm = [...new Set(keys.map(normKey))];
195
221
  for (const k of norm) {
196
- const l = this.limits.get(k);
222
+ const repeat = k.startsWith(`${REPEAT_KEY}:`);
223
+ const l = this.limits.get(repeat ? REPEAT_KEY : k);
197
224
  if (!l) continue;
198
225
  const used = this.used.get(k) ?? 0;
199
226
  if (used >= l.limit) {
200
227
  this.refused++;
201
228
  return {
202
229
  key: l.key, used, limit: l.limit,
203
- message: `limit reached: ${l.key} ${used} of ${l.limit} in this step — work with what you have, or say what more you would need`,
230
+ message: repeat
231
+ ? `limit reached: this exact call (same tool, same input) ${used} of ${l.limit} times in this step — repeating it will not change the answer; try something different, or say what is stuck`
232
+ : `limit reached: ${l.key} ${used} of ${l.limit} in this step — work with what you have, or say what more you would need`,
204
233
  };
205
234
  }
206
235
  }
@@ -223,7 +252,11 @@ export class CallCounter {
223
252
  /** `limits: web.search 40/40, crm 3/20` — null when no limits are set. */
224
253
  summary(): string | null {
225
254
  if (!this.active) return null;
226
- const parts = [...this.limits.entries()].map(([k, l]) => `${l.key} ${this.used.get(k) ?? 0}/${l.limit}`);
255
+ // repeat: the most any one call was made, against its cap.
256
+ const most = (k: string) => k === REPEAT_KEY
257
+ ? Math.max(0, ...[...this.used.entries()].filter(([u]) => u.startsWith(`${REPEAT_KEY}:`)).map(([, n]) => n))
258
+ : this.used.get(k) ?? 0;
259
+ const parts = [...this.limits.entries()].map(([k, l]) => `${l.key} ${most(k)}/${l.limit}`);
227
260
  return `limits: ${parts.join(", ")}${this.refused ? ` — ${this.refused} call${this.refused === 1 ? "" : "s"} refused` : ""}`;
228
261
  }
229
262
  }
@@ -253,7 +286,7 @@ export function limitKeyProblems(
253
286
  return [n];
254
287
  }).map(normKey));
255
288
  for (const key of Object.keys(limits)) {
256
- if (key === TOTAL_KEY) continue;
289
+ if (key === TOTAL_KEY || key === REPEAT_KEY) continue;
257
290
  const base = key.startsWith("web.") ? "web" : key;
258
291
  const n = normKey(base);
259
292
  if (!known.has(n)) {
package/src/linkable.ts CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
- import matter from "gray-matter";
17
+ import matter from "./matter.ts";
18
18
 
19
19
  import { STORAGE_DIR } from "./store.ts";
20
20
 
package/src/matter.ts ADDED
@@ -0,0 +1,33 @@
1
+ // gray-matter, without its cache.
2
+ //
3
+ // gray-matter keeps every input it is handed in a module-level cache keyed by
4
+ // the text, and it stores the entry BEFORE it parses. When the YAML does not
5
+ // parse, the first call throws — and the entry stays, unparsed. The next call
6
+ // with the same text returns it: no error, `data` empty. So in any process
7
+ // that read a file twice, a broken frontmatter was an error once and valid
8
+ // forever after.
9
+ //
10
+ // That is how one agent file took the worker down on 20 Oct 2026. The source
11
+ // API's save check parsed it first (and threw, caught), the write's own
12
+ // "reject files whose frontmatter doesn't parse" then hit the cache and let it
13
+ // through, and the worker, parsing it fresh at boot, threw on it out of
14
+ // listWorkspaces and never came up — for every account on the box.
15
+ //
16
+ // Passing an options object skips the cache entirely (gray-matter only reads
17
+ // and writes it when there are none), so every call here parses the text it
18
+ // was given. The cost is a parse per call, which is what every caller already
19
+ // believed it was paying. Import this, never "gray-matter" itself: a test in
20
+ // tests/matter.test.ts and foldrun-web's consistency test hold every repo to
21
+ // it.
22
+
23
+ import grayMatter from "gray-matter";
24
+
25
+ type Options = NonNullable<Parameters<typeof grayMatter>[1]>;
26
+
27
+ function matter(input: string, options?: Options) {
28
+ return grayMatter(input, options ?? {});
29
+ }
30
+ matter.stringify = grayMatter.stringify;
31
+ matter.test = grayMatter.test;
32
+
33
+ export default matter;
@@ -110,3 +110,48 @@ export function accountModelBlock(read: (name: string) => string | null): Record
110
110
  export function isClaudeLoginToken(value: string | null | undefined): boolean {
111
111
  return typeof value === "string" && /^sk-ant-oat\d*-/.test(value.trim());
112
112
  }
113
+
114
+ /**
115
+ * The variables a step's own model call is made with: the credential, the
116
+ * endpoint, the tier names a provider renames and its headers, and the
117
+ * egress proxy's name for the key. A declared secret with one of these
118
+ * names would replace the step's model settings in the sandbox, so `check`
119
+ * warns about it and the runner leaves it out (FOL-19).
120
+ */
121
+ export const MODEL_ENV_NAMES: readonly string[] = [
122
+ "ANTHROPIC_API_KEY",
123
+ "ANTHROPIC_AUTH_TOKEN",
124
+ "ANTHROPIC_BASE_URL",
125
+ "ANTHROPIC_CUSTOM_HEADERS",
126
+ "ANTHROPIC_MODEL",
127
+ "ANTHROPIC_SMALL_FAST_MODEL",
128
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
129
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
130
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
131
+ "CLAUDE_CODE_OAUTH_TOKEN",
132
+ "FOLDRUN_MODEL_KEY",
133
+ "FOLDRUN_MODEL_KEY_FALLBACK",
134
+ ];
135
+
136
+ /** How such a secret crosses into the sandbox instead; the driver maps it
137
+ * back for the agent's own tools (run-container.ts). */
138
+ export const DECLARED_ENV_PREFIX = "FOLDRUN_DECLARED_";
139
+
140
+ export function isModelEnvName(name: string): boolean {
141
+ return MODEL_ENV_NAMES.includes(name.trim());
142
+ }
143
+
144
+ /** Why a `secrets:` list names a model variable, or null. */
145
+ export function modelSecretCollision(names: unknown): string | null {
146
+ const list = (Array.isArray(names) ? names : typeof names === "string" ? names.split(",") : [])
147
+ .filter((n): n is string => typeof n === "string")
148
+ .map((n) => n.trim())
149
+ .filter(isModelEnvName);
150
+ if (!list.length) return null;
151
+ const which = list.map((n) => `\`${n}\``).join(", ");
152
+ return (
153
+ `${which} ${list.length === 1 ? "is a name" : "are names"} the step's own model call uses, so the model keeps its own value: ` +
154
+ "its script and API tools and `verify:` get the declared one, but its own shell sees the model's — " +
155
+ "give the secret another name (MY_ANTHROPIC_KEY), or run the model on it with `provider:` or `foldrun account model set`"
156
+ );
157
+ }
package/src/model-env.ts CHANGED
@@ -11,6 +11,8 @@ import { readAgentsMd } from "./runner.ts";
11
11
  import { getSecret } from "./secrets.ts";
12
12
  import { platform } from "./platform.ts";
13
13
  import { accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
14
+ import { providerPreset } from "./providers.ts";
15
+ import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./translator.ts";
14
16
 
15
17
  /** Refused for want of a usable key; `status` is what an HTTP layer answers. */
16
18
  export class ModelKeyError extends Error {
@@ -28,14 +30,20 @@ export const NO_MODEL_KEY =
28
30
  /**
29
31
  * `platform` is a function: the caller's own way to build the platform's env
30
32
  * (a step's, or the SDK's host env), called only when this account may use
31
- * it. `format` says what the endpoint speaks — a caller with no translator
32
- * refuses anything but anthropic.
33
+ * it. `format` says what the endpoint speaks; `translator` is set when it is
34
+ * not Anthropic's, and `withModelEnv` puts the step's translator in front of
35
+ * it, so a judge or a draft works on any key a run works on (FOL-16).
33
36
  */
34
37
  export function accountModelEnv(
35
38
  tenant: string,
36
39
  workspace: string,
37
40
  platformEnv: () => Record<string, string | undefined>,
38
- ): { env: Record<string, string | undefined>; supply: "provider" | "platform"; format: string } {
41
+ ): {
42
+ env: Record<string, string | undefined>;
43
+ supply: "provider" | "platform";
44
+ format: string;
45
+ translator: TranslatorSpec | null;
46
+ } {
39
47
  const raw =
40
48
  (readAgentsMd(workspaceDir(tenant, workspace))?.data?.provider as Record<string, unknown> | undefined) ??
41
49
  (readAgentsMd(accountDir(tenant))?.data?.provider as Record<string, unknown> | undefined) ??
@@ -43,7 +51,7 @@ export function accountModelEnv(
43
51
  const spec = parseProvider(raw);
44
52
  if (!spec?.baseUrl) {
45
53
  if (!platform.platformKeyAllowed(tenant)) throw new ModelKeyError(NO_MODEL_KEY);
46
- return { env: platformEnv(), supply: "platform", format: "anthropic" };
54
+ return { env: platformEnv(), supply: "platform", format: "anthropic", translator: null };
47
55
  }
48
56
  const sub = (text: string) =>
49
57
  text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
@@ -52,15 +60,39 @@ export function accountModelEnv(
52
60
  if (!platform.platformKeyAllowed(tenant) && isClaudeLoginToken(token)) {
53
61
  throw new ModelKeyError("model key: a Claude login token (sk-ant-oat…) cannot be used here — use an API key from your provider", 422);
54
62
  }
63
+ const headers = Object.fromEntries(Object.entries(spec.headers).map(([k, v]) => [k, sub(v)]));
64
+ const preset = providerPreset(spec.name);
55
65
  return {
56
- env: providerEnvFor({
66
+ env: providerEnvFor({ baseUrl: spec.baseUrl, token, auth: spec.auth, models: spec.models, headers }),
67
+ supply: "provider",
68
+ format: spec.format,
69
+ translator: translatorSpecFor({
70
+ format: spec.format,
57
71
  baseUrl: spec.baseUrl,
58
72
  token,
59
- auth: spec.auth,
60
- models: spec.models,
61
- headers: Object.fromEntries(Object.entries(spec.headers).map(([k, v]) => [k, sub(v)])),
73
+ headers,
74
+ params: spec.params,
75
+ name: spec.name,
76
+ maxTokensParam: preset?.maxTokensParam,
77
+ reasoningEffort: preset?.reasoningEffort,
62
78
  }),
63
- supply: "provider",
64
- format: spec.format,
65
79
  };
66
80
  }
81
+
82
+ /**
83
+ * Run `fn` with the model env, through a translator on this host when the
84
+ * provider speaks Chat Completions or Responses — the same one a step uses
85
+ * (runner.ts), started for this call and closed after it.
86
+ */
87
+ export async function withModelEnv<T>(
88
+ m: { env: Record<string, string | undefined>; translator?: TranslatorSpec | null },
89
+ fn: (env: Record<string, string | undefined>) => Promise<T>,
90
+ ): Promise<T> {
91
+ if (!m.translator) return fn(m.env);
92
+ const t = await startTranslator(m.translator);
93
+ try {
94
+ return await fn({ ...m.env, ...t.env });
95
+ } finally {
96
+ await t.close();
97
+ }
98
+ }
package/src/network.ts CHANGED
@@ -268,13 +268,37 @@ export function resolveNetwork(args: {
268
268
  }
269
269
 
270
270
  /** One line for the run's trace. */
271
+ /** The sites a step reached, read from the egress lines on its trail
272
+ * (egress-proxy.ts formatDrain): `egress: CONNECT host:port …` and
273
+ * `egress: GET host/path → 200 …`. Lower-cased, port dropped, sorted. */
274
+ export function hostsInTrail(events: { type?: string; text?: string }[]): string[] {
275
+ const hosts = new Set<string>();
276
+ for (const e of events) {
277
+ // A refused or unreachable call reached nothing: the proxy says so on
278
+ // the same line (`… refused — …`, `… → unreachable: …`).
279
+ if (typeof e.text === "string" && /^egress: \S+ \S+ (?:refused|unreachable|→ unreachable)\b/.test(e.text)) continue;
280
+ const m = typeof e.text === "string" ? /^egress: (?:CONNECT ([^\s:]+)|[A-Z]+ ([^\s/:]+))/.exec(e.text) : null;
281
+ const host = (m?.[1] ?? m?.[2])?.toLowerCase();
282
+ if (host) hosts.add(host);
283
+ }
284
+ return [...hosts].sort();
285
+ }
286
+
287
+ /** `network_enforce: true` in the account's or the workspace's AGENTS.md:
288
+ * the steps here have a site off their list refused rather than only named.
289
+ * Either level switches it on; neither can switch off what the other, or the
290
+ * install (FOLDRUN_NETWORK_ENFORCE=1), switched on. */
291
+ export function networkEnforceWanted(account: unknown, workspace: unknown): boolean {
292
+ return account === true || workspace === true;
293
+ }
294
+
271
295
  export function networkLine(policy: NetworkPolicy, enforced: boolean | null): string {
272
296
  const how =
273
297
  enforced === null
274
298
  ? " — not enforced here (no sandbox network)"
275
299
  : enforced
276
300
  ? ""
277
- : " — report only on this install: a site off the list is allowed and named in the trace";
301
+ : " — report only: a site off the list is allowed and named in the trace (network_enforce: true in AGENTS.md refuses it)";
278
302
  if (policy.level === "open") return `network: open (${policy.from}) — any public site${how}`;
279
303
  const parts: string[] = [];
280
304
  if (policy.defaults) parts.push(`the trusted list (${TRUSTED_SITES.length} sites)`);
package/src/notify.ts CHANGED
@@ -372,14 +372,29 @@ export async function sendPlainNotification(
372
372
  workspace: string,
373
373
  msg: { event: string; headline: string; detail: string; flow?: string; runId?: string },
374
374
  ): Promise<boolean> {
375
+ return (await sendPlainNotificationOutcome(tenant, workspace, msg)).ok;
376
+ }
377
+
378
+ /**
379
+ * The same send, saying what became of it: `ok` when it arrived, `retrying`
380
+ * when a webhook's first attempt failed and the delivery log holds it for
381
+ * another (FOL-58 — a caller that would retry on its own must not start a
382
+ * second chain).
383
+ */
384
+ export async function sendPlainNotificationOutcome(
385
+ tenant: string,
386
+ workspace: string,
387
+ msg: { event: string; headline: string; detail: string; flow?: string; runId?: string },
388
+ ): Promise<{ ok: boolean; retrying: boolean }> {
389
+ const no = { ok: false, retrying: false };
375
390
  const config = notifyConfig(tenant, workspace);
376
- if (!config) return false;
391
+ if (!config) return no;
377
392
  // These follow `failed`. Each one IS a failure — a flow that stopped
378
393
  // running, a run that has stalled, a budget about to refuse work — that
379
394
  // happens to produce no failed run for the normal path to report. A
380
395
  // destination hearing about failures hears about these; naming one
381
396
  // explicitly also turns it on; leaving `failed` out silences them.
382
- if (!config.events.includes(msg.event) && !config.events.includes("failed")) return false;
397
+ if (!config.events.includes(msg.event) && !config.events.includes("failed")) return no;
383
398
 
384
399
  const body = {
385
400
  text: `${msg.headline} — ${msg.detail} · ${workspace}`,
@@ -392,7 +407,7 @@ export async function sendPlainNotification(
392
407
  try {
393
408
  if (config.email) {
394
409
  const mail = notifyMail(tenant);
395
- if (!mail) return false;
410
+ if (!mail) return no;
396
411
  const r = await sendMail({
397
412
  tenant,
398
413
  mail,
@@ -402,14 +417,14 @@ export async function sendPlainNotification(
402
417
  subject: `${msg.headline}`.slice(0, 160),
403
418
  text: `${msg.headline}\n\n${msg.detail}\n\nworkspace: ${workspace}\n${msg.flow ? `flow: ${msg.flow}\n` : ""}${msg.runId ? `run: ${msg.runId}\n` : ""}`,
404
419
  });
405
- return r.ok;
420
+ return { ok: r.ok, retrying: false };
406
421
  }
407
- if (!webhookTarget(tenant, workspace, config).url) return false;
422
+ if (!webhookTarget(tenant, workspace, config).url) return no;
408
423
  const r = await platform.deliverWebhook(tenant, workspace, { event: msg.event, body: JSON.stringify(body), retry: true });
409
- return r.ok;
424
+ return { ok: r.ok, retrying: r.retrying === true };
410
425
  } catch (err) {
411
426
  console.error(`[foldrun] notify (${msg.event}): ${tenant}/${workspace} →`, err instanceof Error ? err.message : err);
412
- return false;
427
+ return no;
413
428
  }
414
429
  }
415
430
 
package/src/okf.ts CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  import fs from "node:fs";
24
24
  import path from "node:path";
25
- import matter from "gray-matter";
25
+ import matter from "./matter.ts";
26
26
 
27
27
  export const OKF_VERSION = "0.2";
28
28