@foldrun/core 2.4.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 (132) 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 +20 -6
  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/network.d.ts +12 -0
  37. package/dist/src/network.js +25 -1
  38. package/dist/src/notify.d.ts +16 -0
  39. package/dist/src/notify.js +17 -7
  40. package/dist/src/okf.js +1 -1
  41. package/dist/src/online-checks.d.ts +76 -0
  42. package/dist/src/online-checks.js +432 -0
  43. package/dist/src/operator.d.ts +277 -0
  44. package/dist/src/operator.js +176 -2
  45. package/dist/src/otel-config.d.ts +18 -0
  46. package/dist/src/otel-config.js +69 -0
  47. package/dist/src/otel.d.ts +38 -0
  48. package/dist/src/otel.js +85 -0
  49. package/dist/src/outcome.d.ts +40 -0
  50. package/dist/src/outcome.js +77 -0
  51. package/dist/src/package.js +1 -1
  52. package/dist/src/platform.d.ts +1 -1
  53. package/dist/src/prompt-layers.d.ts +30 -0
  54. package/dist/src/prompt-layers.js +77 -0
  55. package/dist/src/provider-health.js +1 -1
  56. package/dist/src/providers.d.ts +5 -0
  57. package/dist/src/providers.js +2 -2
  58. package/dist/src/run-container.d.ts +16 -2
  59. package/dist/src/run-container.js +57 -5
  60. package/dist/src/runner.d.ts +53 -2
  61. package/dist/src/runner.js +356 -70
  62. package/dist/src/runs-filter.d.ts +48 -0
  63. package/dist/src/runs-filter.js +73 -0
  64. package/dist/src/scan.d.ts +19 -1
  65. package/dist/src/scan.js +60 -7
  66. package/dist/src/starter.js +4 -2
  67. package/dist/src/step-exec.d.ts +29 -1
  68. package/dist/src/step-exec.js +106 -6
  69. package/dist/src/store.d.ts +130 -3
  70. package/dist/src/store.js +145 -6
  71. package/dist/src/tokens.d.ts +38 -0
  72. package/dist/src/tokens.js +57 -0
  73. package/dist/src/tool-names.js +1 -1
  74. package/dist/src/tool-programs.js +1 -1
  75. package/dist/src/trace-tree.d.ts +40 -0
  76. package/dist/src/trace-tree.js +156 -0
  77. package/dist/src/translator.d.ts +8 -0
  78. package/dist/src/translator.js +7 -2
  79. package/dist/src/trigger-log.d.ts +1 -1
  80. package/dist/src/workspace-check.d.ts +18 -0
  81. package/dist/src/workspace-check.js +34 -0
  82. package/package.json +2 -2
  83. package/src/agent-activity.ts +32 -0
  84. package/src/agent-import.ts +1 -1
  85. package/src/agent-tools.ts +1 -1
  86. package/src/agents-md.ts +1 -1
  87. package/src/alerts.ts +190 -0
  88. package/src/browser-pod.ts +3 -0
  89. package/src/capture.ts +88 -0
  90. package/src/compare.ts +146 -0
  91. package/src/completions.ts +1 -0
  92. package/src/deploy.ts +184 -10
  93. package/src/egress.ts +12 -2
  94. package/src/evals.ts +19 -6
  95. package/src/frontmatter-fields.ts +9 -0
  96. package/src/frontmatter-schema.ts +3 -0
  97. package/src/history.ts +13 -0
  98. package/src/hooks.ts +1 -1
  99. package/src/insights.ts +174 -0
  100. package/src/issues.ts +396 -0
  101. package/src/library.ts +1 -1
  102. package/src/limits.ts +38 -5
  103. package/src/linkable.ts +1 -1
  104. package/src/matter.ts +33 -0
  105. package/src/model-credential.ts +45 -0
  106. package/src/network.ts +25 -1
  107. package/src/notify.ts +22 -7
  108. package/src/okf.ts +1 -1
  109. package/src/online-checks.ts +450 -0
  110. package/src/operator.ts +213 -3
  111. package/src/otel-config.ts +67 -0
  112. package/src/otel.ts +100 -0
  113. package/src/outcome.ts +90 -0
  114. package/src/package.ts +1 -1
  115. package/src/platform.ts +1 -1
  116. package/src/prompt-layers.ts +90 -0
  117. package/src/provider-health.ts +1 -1
  118. package/src/providers.ts +7 -2
  119. package/src/run-container.ts +66 -7
  120. package/src/runner.ts +363 -46
  121. package/src/runs-filter.ts +88 -0
  122. package/src/scan.ts +68 -7
  123. package/src/starter.ts +4 -2
  124. package/src/step-exec.ts +147 -9
  125. package/src/store.ts +254 -8
  126. package/src/tokens.ts +68 -0
  127. package/src/tool-names.ts +1 -1
  128. package/src/tool-programs.ts +1 -1
  129. package/src/trace-tree.ts +187 -0
  130. package/src/translator.ts +13 -2
  131. package/src/trigger-log.ts +1 -0
  132. package/src/workspace-check.ts +41 -0
@@ -0,0 +1,156 @@
1
+ // A run as one tree (FOL-72 step 5): run → step → model call → tool call,
2
+ // each with when it started and ended, what it cost in tokens and how it
3
+ // ended — the waterfall every agent-tracing tool opens on. Built from the
4
+ // run record alone (steps, step.calls, the paired tool events), so the run
5
+ // page, GET …/runs/<id>/trace and `foldrun report --tree` read one function.
6
+ //
7
+ // A tool call nests under the model call that asked for it: its event names
8
+ // the call (`inCall`) since 19 Oct 2026; older records are matched by time,
9
+ // to the last model call that answered before the tool started. Pure: no
10
+ // node imports, so the browser can use it.
11
+ const ms = (iso) => {
12
+ if (!iso)
13
+ return null;
14
+ const t = Date.parse(iso);
15
+ return Number.isNaN(t) ? null : t;
16
+ };
17
+ function stepStatus(s) {
18
+ if (s.status === "completed")
19
+ return "ok";
20
+ if (s.status === "failed")
21
+ return "error";
22
+ if (s.status === "running")
23
+ return "running";
24
+ if (s.status === "awaiting-approval")
25
+ return "waiting";
26
+ if (s.status === "skipped")
27
+ return "skipped";
28
+ return "pending";
29
+ }
30
+ /** Tool events paired: the call's start, its duration, whether it erred. */
31
+ function toolCalls(events) {
32
+ const byCall = new Map();
33
+ const out = [];
34
+ for (const e of events) {
35
+ if (e.type !== "tool")
36
+ continue;
37
+ if (e.call && e.ms != null && byCall.has(e.call)) {
38
+ const open = byCall.get(e.call);
39
+ open.end = (open.start ?? ms(e.t) ?? 0) + e.ms;
40
+ open.err ||= e.err === true;
41
+ open.captured ||= e.captured === true;
42
+ continue;
43
+ }
44
+ const c = { e, start: ms(e.t), end: null, err: e.err === true, captured: e.captured === true };
45
+ if (e.call)
46
+ byCall.set(e.call, c);
47
+ out.push(c);
48
+ }
49
+ return out;
50
+ }
51
+ function modelSpan(c, step) {
52
+ const at = ms(c.at);
53
+ return {
54
+ id: `s${step}/m${c.attempt && c.attempt > 1 ? `${c.attempt}.` : ""}${c.n}`,
55
+ kind: "model",
56
+ label: c.model ?? "model",
57
+ // The request went out `ms` before the response began arriving.
58
+ start: at !== null ? at - c.ms : null,
59
+ end: at,
60
+ status: "ok",
61
+ tokens: c.tokens,
62
+ ...(c.model ? { model: c.model } : {}),
63
+ ...(c.stop ? { stop: c.stop } : {}),
64
+ ...(c.subagent ? { subagent: c.subagent } : {}),
65
+ step,
66
+ children: [],
67
+ };
68
+ }
69
+ function stepSpan(s, i, now) {
70
+ const first = s.events[0] ? ms(s.events[0].t) : null;
71
+ const last = s.events.length ? ms(s.events[s.events.length - 1].t) : null;
72
+ const status = stepStatus(s);
73
+ const span = {
74
+ id: `s${i}`,
75
+ kind: "step",
76
+ label: s.agent,
77
+ start: ms(s.startedAt) ?? first,
78
+ end: ms(s.finishedAt) ?? (status === "running" ? now : last),
79
+ status,
80
+ ...(s.tokens ? { tokens: { input: s.tokens.input, output: s.tokens.output, cacheRead: s.tokens.cacheRead ?? 0, cacheWrite: s.tokens.cacheWrite ?? 0 } } : {}),
81
+ costUsd: s.costUsd ?? null,
82
+ ...(s.model ? { model: s.model } : {}),
83
+ step: i,
84
+ children: [],
85
+ };
86
+ const models = (s.calls ?? []).map((c) => ({ c, span: modelSpan(c, i) }));
87
+ const byId = new Map(models.map((m) => [m.c.id, m.span]));
88
+ const homeOf = (e, start) => {
89
+ if (e.inCall && byId.has(e.inCall))
90
+ return byId.get(e.inCall);
91
+ // Older records: the last model call (of the same agent) that answered
92
+ // before the tool started.
93
+ const same = models.filter((m) => (m.c.subagent ?? null) === (e.subagent ?? null) && m.span.end !== null && start !== null && m.span.end <= start + 50);
94
+ return same.length ? same[same.length - 1].span : span;
95
+ };
96
+ for (const t of toolCalls(s.events)) {
97
+ const home = homeOf(t.e, t.start);
98
+ const tool = {
99
+ id: `s${i}/t:${t.e.call ?? `${t.start}`}`,
100
+ kind: "tool",
101
+ label: t.e.text,
102
+ start: t.start,
103
+ end: t.end ?? (status === "running" ? now : t.start),
104
+ status: t.err ? "error" : t.end === null && status === "running" ? "running" : "ok",
105
+ ...(t.e.call ? { call: t.e.call } : {}),
106
+ ...(t.captured ? { captured: true } : {}),
107
+ ...(t.e.subagent ? { subagent: t.e.subagent } : {}),
108
+ step: i,
109
+ children: [],
110
+ };
111
+ home.children.push(tool);
112
+ }
113
+ // A model call lasts until the last tool it asked for has finished.
114
+ for (const m of models) {
115
+ const ends = m.span.children.map((c) => c.end).filter((x) => x !== null);
116
+ if (ends.length && m.span.end !== null)
117
+ m.span.end = Math.max(m.span.end, ...ends);
118
+ }
119
+ span.children.push(...models.map((m) => m.span));
120
+ // Errors, as leaves: why the step failed, read without opening the trace.
121
+ s.events.forEach((e, k) => {
122
+ if (e.type !== "error")
123
+ return;
124
+ span.children.push({ id: `s${i}/e${k}`, kind: "error", label: "error", start: ms(e.t), end: ms(e.t), status: "error", text: e.text, step: i, children: [] });
125
+ });
126
+ span.children.sort((a, b) => (a.start ?? Infinity) - (b.start ?? Infinity));
127
+ return span;
128
+ }
129
+ export function buildTraceTree(run, now = Date.now()) {
130
+ const status = run.status === "completed" ? "ok" : run.status === "failed" ? "error" : run.status === "awaiting-approval" ? "waiting" : run.status === "queued" ? "pending" : "running";
131
+ return {
132
+ id: "run",
133
+ kind: "run",
134
+ label: run.flow,
135
+ start: ms(run.startedAt),
136
+ end: ms(run.finishedAt) ?? (status === "running" || status === "waiting" ? now : null),
137
+ status,
138
+ children: run.steps.map((s, i) => stepSpan(s, i, now)),
139
+ };
140
+ }
141
+ /** The ids from the root to the first thing that went wrong — what the
142
+ * view opens on; [] when nothing did. A failed tool beats the step's error
143
+ * line, which beats the failed step itself. */
144
+ export function failurePath(root) {
145
+ let best = null;
146
+ const rank = (s) => (s.kind === "tool" ? 3 : s.kind === "error" ? 2 : s.kind === "step" ? 1 : 0);
147
+ const walk = (s, path) => {
148
+ const here = [...path, s.id];
149
+ if (s.status === "error" && s.kind !== "run" && (!best || rank(s) > best.rank))
150
+ best = { path: here, rank: rank(s) };
151
+ for (const c of s.children)
152
+ walk(c, here);
153
+ };
154
+ walk(root, []);
155
+ return best ? best.path : [];
156
+ }
@@ -28,6 +28,11 @@ export interface TranslatorSpec {
28
28
  /** The provider block's `params:` — merged into the outgoing body after
29
29
  * the translation, so a file can set what this format does not model. */
30
30
  params?: Record<string, unknown>;
31
+ /** `prompt_cache_key` for an endpoint that routes its prompt cache by
32
+ * one (OpenAI, Mistral): the same key for every request that shares a
33
+ * prefix — one per agent (prompt-layers.ts). Unset where the preset
34
+ * does not say the parameter is understood. */
35
+ cacheKey?: string;
31
36
  /** How the endpoint is named in the trace. */
32
37
  label?: string;
33
38
  /** The step's egress lease, when the sandbox must not hold the key: the
@@ -77,6 +82,7 @@ export declare function toChatCompletions(req: Json, opts: {
77
82
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
78
83
  reasoningEffort?: boolean;
79
84
  stream: boolean;
85
+ cacheKey?: string;
80
86
  /** Merged in last, so the file wins over the translation. */
81
87
  params?: Record<string, unknown>;
82
88
  }, drop?: Dropped): Json;
@@ -93,6 +99,7 @@ export declare function toChatCompletions(req: Json, opts: {
93
99
  export declare function toResponses(req: Json, opts: {
94
100
  reasoningEffort?: boolean;
95
101
  stream: boolean;
102
+ cacheKey?: string;
96
103
  params?: Record<string, unknown>;
97
104
  }, drop?: Dropped): Json;
98
105
  /** A finished Chat Completion as an Anthropic message. Pure. */
@@ -173,5 +180,6 @@ export declare function translatorSpecFor(spec: {
173
180
  name?: string | null;
174
181
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
175
182
  reasoningEffort?: boolean;
183
+ cacheKey?: string;
176
184
  }): TranslatorSpec | null;
177
185
  export {};
@@ -123,6 +123,8 @@ function imagePart(block) {
123
123
  */
124
124
  export function toChatCompletions(req, opts, drop = dropped()) {
125
125
  const out = { model: req.model, stream: opts.stream };
126
+ if (opts.cacheKey)
127
+ out.prompt_cache_key = opts.cacheKey;
126
128
  const messages = [];
127
129
  const system = textOf(req.system);
128
130
  if (system)
@@ -285,6 +287,8 @@ export function toChatCompletions(req, opts, drop = dropped()) {
285
287
  */
286
288
  export function toResponses(req, opts, drop = dropped()) {
287
289
  const out = { model: req.model, stream: opts.stream, store: false };
290
+ if (opts.cacheKey)
291
+ out.prompt_cache_key = opts.cacheKey;
288
292
  const system = textOf(req.system);
289
293
  if (system)
290
294
  out.instructions = system;
@@ -904,8 +908,8 @@ export async function startTranslator(spec) {
904
908
  const drop = dropped();
905
909
  const responses = spec.wire === "responses";
906
910
  const outbound = responses
907
- ? toResponses(body, { reasoningEffort: spec.reasoningEffort, params: spec.params, stream }, drop)
908
- : toChatCompletions(body, { maxTokensParam: spec.maxTokensParam, reasoningEffort: spec.reasoningEffort, params: spec.params, stream }, drop);
911
+ ? toResponses(body, { reasoningEffort: spec.reasoningEffort, params: spec.params, cacheKey: spec.cacheKey, stream }, drop)
912
+ : toChatCompletions(body, { maxTokensParam: spec.maxTokensParam, reasoningEffort: spec.reasoningEffort, params: spec.params, cacheKey: spec.cacheKey, stream }, drop);
909
913
  for (const line of drop.lines())
910
914
  if (!log.includes(line))
911
915
  log.push(line);
@@ -1051,6 +1055,7 @@ export function translatorSpecFor(spec) {
1051
1055
  params: spec.params,
1052
1056
  maxTokensParam: spec.maxTokensParam,
1053
1057
  reasoningEffort: spec.reasoningEffort,
1058
+ ...(spec.cacheKey ? { cacheKey: spec.cacheKey } : {}),
1054
1059
  label: spec.name ? `${spec.name} (${spec.baseUrl})` : spec.baseUrl,
1055
1060
  };
1056
1061
  }
@@ -1,7 +1,7 @@
1
1
  /** What became of one trigger firing. `started` is here too: without it the
2
2
  * log answers "why did nothing run" and not "how often does anything",
3
3
  * and the ratio is the whole point of the page. */
4
- export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "overlap-skipped" | "missed" | "failed";
4
+ export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "halted" | "overlap-skipped" | "missed" | "failed";
5
5
  export interface TriggerEvent {
6
6
  t: string;
7
7
  flow: string;
@@ -0,0 +1,18 @@
1
+ import type { DeployFile } from "./store.ts";
2
+ import type { ScanFinding, ScanOptions } from "./scan.ts";
3
+ /** What the checks read of a workspace, at most. Data (state/ and memory/,
4
+ * what agents write) past these is left out and named; the rest past its
5
+ * budget is not read at all (TreeTooLarge). On 20 Oct 2026 the daily
6
+ * rescan read 1.2 GB of one desk's state/ into a 2 GB heap and crash-looped
7
+ * the worker. The rest is 64 MiB because a deploy is 10 MiB a push. */
8
+ export declare const CHECK_LIMITS: {
9
+ readonly maxDataFileBytes: number;
10
+ readonly maxDataBytes: number;
11
+ readonly maxDataFiles: 5000;
12
+ readonly maxSourceBytes: number;
13
+ };
14
+ /** A workspace as the checks read it: the deploy's tree within CHECK_LIMITS.
15
+ * Read once and hand it to everything that checks. */
16
+ export declare function checkTree(dir: string, onSkip?: (path: string, bytes: number) => void): DeployFile[];
17
+ export declare function checkOptions(tenant: string, workspace: string): ScanOptions;
18
+ export declare function checkWorkspace(tenant: string, workspace: string, tree?: DeployFile[]): ScanFinding[];
@@ -0,0 +1,34 @@
1
+ // One workspace's findings, as the Checks page, `GET …/check` and the daily
2
+ // rescan see them (FOL-58): the deploy's checks and the whole scanner, with
3
+ // what the files alone do not carry — the account's network: and the
4
+ // outward tools of the library and the workspace, nearest wins.
5
+ import { accountDir, workspaceDir, workspaceTools } from "./store.js";
6
+ import { readAgentsMdKeys } from "./agents-md.js";
7
+ import { libraryTools } from "./library.js";
8
+ import { checkFiles, readTree } from "./deploy.js";
9
+ /** What the checks read of a workspace, at most. Data (state/ and memory/,
10
+ * what agents write) past these is left out and named; the rest past its
11
+ * budget is not read at all (TreeTooLarge). On 20 Oct 2026 the daily
12
+ * rescan read 1.2 GB of one desk's state/ into a 2 GB heap and crash-looped
13
+ * the worker. The rest is 64 MiB because a deploy is 10 MiB a push. */
14
+ export const CHECK_LIMITS = {
15
+ maxDataFileBytes: 5 * 1024 * 1024,
16
+ maxDataBytes: 32 * 1024 * 1024,
17
+ maxDataFiles: 5000,
18
+ maxSourceBytes: 64 * 1024 * 1024,
19
+ };
20
+ /** A workspace as the checks read it: the deploy's tree within CHECK_LIMITS.
21
+ * Read once and hand it to everything that checks. */
22
+ export function checkTree(dir, onSkip) {
23
+ return readTree(dir, { ...CHECK_LIMITS, onSkip });
24
+ }
25
+ export function checkOptions(tenant, workspace) {
26
+ const tools = { ...libraryTools(tenant), ...workspaceTools(tenant, workspace) };
27
+ const outwardTools = Object.entries(tools).filter(([, d]) => d.outward === true).map(([n]) => n);
28
+ // AGENTS.md, or the older project.md, as the runner reads it (Codex review).
29
+ const accountNetwork = readAgentsMdKeys(accountDir(tenant)).network;
30
+ return { outwardTools, accountNetwork };
31
+ }
32
+ export function checkWorkspace(tenant, workspace, tree = checkTree(workspaceDir(tenant, workspace))) {
33
+ return checkFiles(tree, checkOptions(tenant, workspace));
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -53,7 +53,7 @@
53
53
  "node": ">=22"
54
54
  },
55
55
  "dependencies": {
56
- "@anthropic-ai/claude-agent-sdk": "^0.3.285",
56
+ "@anthropic-ai/claude-agent-sdk": "^0.3.296",
57
57
  "gray-matter": "^4.0.3",
58
58
  "zod": "4.4.3"
59
59
  },
@@ -0,0 +1,32 @@
1
+ // When each agent last did anything — the question an account with hundreds
2
+ // of agents has to be able to answer before it can retire the ones nobody
3
+ // uses. Read from the run index (listRunSummaries), so it covers what the
4
+ // install keeps: runs older than its retention are gone, and an agent whose
5
+ // last run was pruned reads as never run.
6
+
7
+ import type { RunSummary } from "./store.ts";
8
+
9
+ /** A step that started counts; one that was skipped or never reached does not. */
10
+ const RAN = new Set(["running", "completed", "failed", "awaiting-approval"]);
11
+
12
+ /** Agent name → the ISO time of its latest step that ran. */
13
+ export function agentLastRuns(runs: RunSummary[]): Map<string, string> {
14
+ const last = new Map<string, string>();
15
+ for (const run of runs) {
16
+ for (const step of run.steps ?? []) {
17
+ if (!step.agent || !RAN.has(step.status)) continue;
18
+ const at = step.startedAt ?? run.startedAt;
19
+ if (!at) continue;
20
+ const prev = last.get(step.agent);
21
+ if (!prev || at > prev) last.set(step.agent, at);
22
+ }
23
+ }
24
+ return last;
25
+ }
26
+
27
+ /** Whole days since `iso`, or null for never. */
28
+ export function daysSince(iso: string | null | undefined, now = Date.now()): number | null {
29
+ if (!iso) return null;
30
+ const t = Date.parse(iso);
31
+ return Number.isFinite(t) ? Math.floor((now - t) / 86_400_000) : null;
32
+ }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import fs from "node:fs";
17
17
  import path from "node:path";
18
- import matter from "gray-matter";
18
+ import matter from "./matter.ts";
19
19
  import {
20
20
  assertSafeName,
21
21
  listWorkspaceFiles,
@@ -17,7 +17,7 @@
17
17
  import fs from "node:fs";
18
18
  import os from "node:os";
19
19
  import path from "node:path";
20
- import matter from "gray-matter";
20
+ import matter from "./matter.ts";
21
21
  import { z } from "zod";
22
22
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
23
23
  import { resolveModel, resolveEffort, type Effort } from "./store.ts";
package/src/agents-md.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
- import matter from "gray-matter";
12
+ import matter from "./matter.ts";
13
13
 
14
14
  const FRONT = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n|$)/;
15
15
 
package/src/alerts.ts ADDED
@@ -0,0 +1,190 @@
1
+ // `alerts:` — the thresholds a workspace wants to hear about (FOL-72 step 7):
2
+ //
3
+ // alerts:
4
+ // failure_rate: 30% # of its runs that finished in the last 24 hours
5
+ // min_runs: 5 # …once at least this many have (default 5)
6
+ // credits_day: 2000 # credits charged in the last 24 hours
7
+ // run_minutes: 45 # a run still going after this long
8
+ // stale_days: 30 # an agent that has not run in this many days
9
+ // cost_spike: 3 # a run that cost this many times its flow's 7-day average
10
+ // runs_per_hour: 20 # one flow started this many times in an hour
11
+ // new_host: true # an agent reached a site it never reached before
12
+ //
13
+ // In AGENTS.md (the workspace's, else the account's). The watchdog evaluates
14
+ // them on its tick and tells `notify:` once per rule a day (a slow run once
15
+ // per run), as event `alert`. Pure: `foldrun check` and the watchdog read the
16
+ // same parse.
17
+
18
+ import type { Outcome } from "./outcome.ts";
19
+
20
+ export interface AlertConfig {
21
+ failureRate?: number;
22
+ minRuns: number;
23
+ creditsDay?: number;
24
+ runMinutes?: number;
25
+ staleDays?: number;
26
+ costSpike?: number;
27
+ runsPerHour?: number;
28
+ /** Told after a run (platform host-watch.ts), not on the watchdog's tick. */
29
+ newHost?: boolean;
30
+ }
31
+
32
+ const KEYS = ["failure_rate", "min_runs", "credits_day", "run_minutes", "stale_days", "cost_spike", "runs_per_hour", "new_host"];
33
+
34
+ const num = (v: unknown): number | null => {
35
+ if (typeof v === "number" && Number.isFinite(v)) return v;
36
+ if (typeof v === "string" && v.trim() && Number.isFinite(Number(v.trim()))) return Number(v.trim());
37
+ return null;
38
+ };
39
+
40
+ /** The config, and what is wrong with it in words (for `foldrun check`). */
41
+ export function parseAlerts(raw: unknown): { config: AlertConfig | null; problems: string[] } {
42
+ if (raw === undefined || raw === null) return { config: null, problems: [] };
43
+ if (typeof raw !== "object" || Array.isArray(raw)) {
44
+ return { config: null, problems: ["alerts: is a block — failure_rate, min_runs, credits_day, run_minutes, stale_days, cost_spike, runs_per_hour, new_host"] };
45
+ }
46
+ const o = raw as Record<string, unknown>;
47
+ const problems: string[] = [];
48
+ for (const k of Object.keys(o)) if (!KEYS.includes(k)) problems.push(`alerts: ${k} — not a threshold; the keys are ${KEYS.join(", ")}`);
49
+ const cfg: AlertConfig = { minRuns: 5 };
50
+ if (o.failure_rate !== undefined) {
51
+ const s = String(o.failure_rate).trim();
52
+ const pct = /^(\d+(?:\.\d+)?)\s*%$/.exec(s);
53
+ const v = pct ? Number(pct[1]) / 100 : num(o.failure_rate);
54
+ if (v === null || v <= 0 || v > 1) problems.push(`alerts: failure_rate ${s} — a share of runs, like 30% or 0.3`);
55
+ else cfg.failureRate = v;
56
+ }
57
+ for (const [key, field, min] of [["min_runs", "minRuns", 1], ["credits_day", "creditsDay", 1], ["run_minutes", "runMinutes", 1], ["stale_days", "staleDays", 1], ["cost_spike", "costSpike", 1.5], ["runs_per_hour", "runsPerHour", 2]] as const) {
58
+ if (o[key] === undefined) continue;
59
+ const v = num(o[key]);
60
+ if (v === null || v < min) problems.push(`alerts: ${key} ${String(o[key])} — a number, at least ${min}`);
61
+ else (cfg as unknown as Record<string, number>)[field] = v;
62
+ }
63
+ if (o.new_host !== undefined) {
64
+ if (typeof o.new_host !== "boolean") problems.push(`alerts: new_host ${String(o.new_host)} — true or false`);
65
+ else if (o.new_host) cfg.newHost = true;
66
+ }
67
+ const any = cfg.newHost === true || cfg.failureRate !== undefined || cfg.creditsDay !== undefined || cfg.runMinutes !== undefined || cfg.staleDays !== undefined || cfg.costSpike !== undefined || cfg.runsPerHour !== undefined;
68
+ return { config: any ? cfg : null, problems };
69
+ }
70
+
71
+ export interface AlertFacts {
72
+ /** Outcomes of the runs started in the last 24 hours. */
73
+ outcomes: Outcome[];
74
+ /** Credits are cents: USD charged in the last 24 hours. */
75
+ chargedUsd: number;
76
+ /** Runs still going. */
77
+ live: { id: string; flow: string; startedAt: string }[];
78
+ /** For stale_days: each agent, when it last ran (null: not in the runs
79
+ * kept), and since when it has existed — an agent added yesterday is not
80
+ * stale for never having run. */
81
+ agents?: { name: string; lastRunAt: string | null; since: string }[];
82
+ /** For cost_spike and runs_per_hour: the runs of the last seven days,
83
+ * test runs left out. costUsd is what its steps cost. */
84
+ runs?: { id: string; flow: string; startedAt: string; finishedAt: string | null; costUsd: number }[];
85
+ }
86
+
87
+ /** How few earlier runs a flow needs before its average means anything. */
88
+ const SPIKE_MIN_RUNS = 3;
89
+
90
+ export interface Alert {
91
+ /** What the once-a-day memory is keyed by: the rule, or the rule and run. */
92
+ key: string;
93
+ headline: string;
94
+ detail: string;
95
+ runId?: string;
96
+ flow?: string;
97
+ /** Said once for this key, however long it stays true (a slow run, an
98
+ * idle spell); the watchdog's notice memory is pruned after a week, so
99
+ * a condition that lasts is said again weekly at most. */
100
+ once?: boolean;
101
+ }
102
+
103
+ /** What crosses its threshold now. */
104
+ export function evaluateAlerts(cfg: AlertConfig, facts: AlertFacts, workspace: string, now: number): Alert[] {
105
+ const out: Alert[] = [];
106
+ if (cfg.failureRate !== undefined) {
107
+ const finished = facts.outcomes.filter((o) => o === "failed" || o === "good" || o === "done" || o === "quiet" || o === "blocked" || o === "bad");
108
+ const failed = finished.filter((o) => o === "failed").length;
109
+ if (finished.length >= cfg.minRuns && failed / finished.length >= cfg.failureRate) {
110
+ out.push({
111
+ key: "failure_rate",
112
+ headline: `⚠ ${workspace}: ${failed} of its last ${finished.length} runs failed`,
113
+ detail: `${Math.round((100 * failed) / finished.length)}% in the last 24 hours, over the ${Math.round(cfg.failureRate * 100)}% in alerts: failure_rate. Its Issues show what broke.`,
114
+ });
115
+ }
116
+ }
117
+ if (cfg.creditsDay !== undefined) {
118
+ const credits = Math.round(facts.chargedUsd * 100);
119
+ if (credits >= cfg.creditsDay) {
120
+ out.push({
121
+ key: "credits_day",
122
+ headline: `⚠ ${workspace} was charged ${credits.toLocaleString("en")} credits in a day`,
123
+ detail: `Over the ${cfg.creditsDay.toLocaleString("en")} in alerts: credits_day, in the last 24 hours. Observe shows which flows and agents.`,
124
+ });
125
+ }
126
+ }
127
+ if (cfg.runMinutes !== undefined) {
128
+ for (const r of facts.live) {
129
+ const mins = (now - Date.parse(r.startedAt)) / 60_000;
130
+ if (mins >= cfg.runMinutes) {
131
+ out.push({
132
+ key: `run_minutes/${r.id}`,
133
+ runId: r.id,
134
+ flow: r.flow,
135
+ headline: `⚠ ${workspace}: ${r.flow} has been running ${Math.round(mins)} minutes`,
136
+ detail: `Run ${r.id} is past the ${cfg.runMinutes} minutes in alerts: run_minutes — still going, maybe stuck. Stop it from its page if it is.`,
137
+ });
138
+ }
139
+ }
140
+ }
141
+ if (cfg.costSpike !== undefined && facts.runs) {
142
+ // A run that finished in the last day, against the runs of its flow that
143
+ // finished before it in the week: once per run.
144
+ const done = facts.runs.filter((r) => r.finishedAt).sort((a, b) => a.finishedAt!.localeCompare(b.finishedAt!));
145
+ for (const r of done) {
146
+ if (now - Date.parse(r.finishedAt!) > 86_400_000) continue;
147
+ const before = done.filter((o) => o.flow === r.flow && o.id !== r.id && o.finishedAt! < r.finishedAt!);
148
+ if (before.length < SPIKE_MIN_RUNS) continue;
149
+ const mean = before.reduce((n, o) => n + o.costUsd, 0) / before.length;
150
+ if (mean <= 0 || r.costUsd < cfg.costSpike * mean) continue;
151
+ out.push({
152
+ key: `cost_spike/${r.id}`,
153
+ runId: r.id,
154
+ flow: r.flow,
155
+ headline: `${workspace}: a ${r.flow} run cost ${(r.costUsd / mean).toFixed(1)}× its usual`,
156
+ detail: `Run ${r.id} cost $${r.costUsd.toFixed(2)} against an average of $${mean.toFixed(2)} over its last ${before.length} runs this week — past the ${cfg.costSpike}× in alerts: cost_spike. Its page shows which step spent it.`,
157
+ });
158
+ }
159
+ }
160
+ if (cfg.runsPerHour !== undefined && facts.runs) {
161
+ const hour = facts.runs.filter((r) => now - Date.parse(r.startedAt) <= 3_600_000);
162
+ const byFlow = new Map<string, number>();
163
+ for (const r of hour) byFlow.set(r.flow, (byFlow.get(r.flow) ?? 0) + 1);
164
+ for (const [flow, n] of byFlow) {
165
+ if (n < cfg.runsPerHour) continue;
166
+ out.push({
167
+ key: `runs_per_hour/${flow}`,
168
+ flow,
169
+ headline: `${workspace}: ${flow} started ${n} times in an hour`,
170
+ detail: `Past the ${cfg.runsPerHour} in alerts: runs_per_hour — a loop, a webhook sender retrying, or a schedule written wrong. Its Triggers page shows what started each; throttle: or debounce: caps it, and Settings → Pause stops it now.`,
171
+ });
172
+ }
173
+ }
174
+ if (cfg.staleDays !== undefined) {
175
+ for (const a of facts.agents ?? []) {
176
+ const from = Date.parse(a.lastRunAt ?? a.since);
177
+ if (!Number.isFinite(from)) continue;
178
+ const days = Math.floor((now - from) / 86_400_000);
179
+ if (days < cfg.staleDays) continue;
180
+ out.push({
181
+ // Keyed by the run it last made, so a run and a new idle spell are news again.
182
+ key: `stale_days/${a.name}/${a.lastRunAt ?? "never"}`,
183
+ once: true,
184
+ headline: `${workspace}: agent ${a.name} has not run in ${days} days`,
185
+ detail: `${a.lastRunAt ? `Its last step started ${a.lastRunAt.slice(0, 10)}` : "No run kept on record has used it"} — past the ${cfg.staleDays} days in alerts: stale_days. If nothing needs it any more, retire it: delete agents/${a.name}/ and redeploy, so it holds no tools or secrets nobody watches.`,
186
+ });
187
+ }
188
+ }
189
+ return out;
190
+ }
@@ -159,6 +159,9 @@ export function classifyCall(tool: string, input: Record<string, unknown> | unde
159
159
  const [, server, name] = mcp;
160
160
  if (READ_SERVERS.has(server)) return { write: false };
161
161
  if (server === "foldrun_ask") return write("a question to a person");
162
+ // tools: [peers] — reading the roster changes nothing; a message to a
163
+ // colleague is said, and a re-run would say it twice.
164
+ if (server === "foldrun_peers") return name === "peers_list" ? { write: false } : write("a message to a colleague");
162
165
  if (server === "foldrun_scripts") {
163
166
  if (name !== "web") return write(`script ${name}`);
164
167
  const action = String(args.action ?? "").trim().toLowerCase();
package/src/capture.ts ADDED
@@ -0,0 +1,88 @@
1
+ // A tool call's content, from a step's event to the file beside its run
2
+ // (FOL-54 step 4), masked twice: the step's own secrets by value (the
3
+ // runner's redact, from redactionsFor), then anything shaped like a
4
+ // credential or a private key — a key the agent read from a file or a page
5
+ // is not in the vault, and is still a key. Masked first, cut second, so a
6
+ // secret on the cap's edge is never kept half. Recording never fails the step.
7
+
8
+ import { redactText } from "./scan.ts";
9
+ import { writeCapturedCall, CAPTURE_CAP } from "./store.ts";
10
+ import { isFileValue, fileContent } from "./secrets.ts";
11
+ import type { CallIo } from "./step-exec.ts";
12
+
13
+ /** How much past the cap a step sends, so masking sees a secret whole
14
+ * before the cut (a value longer than this is a file secret, which is also
15
+ * masked line by line). */
16
+ export const CAPTURE_SLACK = 4096;
17
+
18
+ /**
19
+ * What the step's scrub replaces: every secret value it holds → its name,
20
+ * longest first. An `@file` secret (a key, a certificate) by its content, not
21
+ * its stored form; a multi-line value also line by line, because a file is
22
+ * read back a line at a time (the Read tool numbers each); and every value
23
+ * also as it appears inside JSON, where `"` `\` and newlines are escaped — a
24
+ * tool's input is JSON. Values under 8 characters are skipped: a two-letter
25
+ * secret would redact half the language, and anything that short is not one.
26
+ */
27
+ export function redactionsFor(secrets: Record<string, string | undefined>): [string, string][] {
28
+ const out = new Map<string, string>();
29
+ const add = (v: string, name: string) => {
30
+ if (v.length >= 8 && !out.has(v)) out.set(v, name);
31
+ };
32
+ for (const [name, raw] of Object.entries(secrets)) {
33
+ if (typeof raw !== "string") continue;
34
+ for (const v of isFileValue(raw) ? [raw, fileContent(raw)] : [raw]) {
35
+ add(v, name);
36
+ add(v.trim(), name);
37
+ const escaped = JSON.stringify(v).slice(1, -1);
38
+ if (escaped !== v) add(escaped, name);
39
+ if (/\r?\n/.test(v.trim())) {
40
+ for (const line of v.split(/\r?\n/)) {
41
+ const l = line.trim();
42
+ if (l.length >= 16) add(l, name);
43
+ }
44
+ }
45
+ }
46
+ }
47
+ return [...out.entries()].sort((a, b) => b[0].length - a[0].length);
48
+ }
49
+
50
+ // A private key's whole block — scan.ts names a key header and leaves the
51
+ // body, which is right for a finding and wrong for a stored output.
52
+ // Unterminated too: a key cut short, or printed without its END line, is
53
+ // masked to the end of the text.
54
+ const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$(?![\s\S]))/g;
55
+
56
+ /** Masked, then cut to the cap. */
57
+ export function maskCall(text: string, redactSecrets: (text: string) => string): { text: string; cut: boolean } {
58
+ const masked = redactText(redactSecrets(text)).replace(PRIVATE_KEY, "[redacted:private key]");
59
+ return masked.length > CAPTURE_CAP ? { text: masked.slice(0, CAPTURE_CAP), cut: true } : { text: masked, cut: false };
60
+ }
61
+
62
+ export function recordCallIo(
63
+ workspaceRoot: string,
64
+ runId: string,
65
+ callId: string,
66
+ tool: string,
67
+ io: CallIo,
68
+ /** The step's own scrub: its resolved secret values → `[redacted:NAME]`. */
69
+ redactSecrets: (text: string) => string,
70
+ ): boolean {
71
+ try {
72
+ const input = io.input === undefined ? null : maskCall(io.input, redactSecrets);
73
+ const output = io.output === undefined ? null : maskCall(io.output, redactSecrets);
74
+ writeCapturedCall(workspaceRoot, runId, {
75
+ id: callId,
76
+ tool,
77
+ // Which part was cut, each on its own: in the sandbox (the whole was
78
+ // longer than what was sent) or here, after masking.
79
+ ...(input ? { input: input.text, ...(io.inputChars !== undefined ? { inputChars: io.inputChars } : {}), ...(input.cut || (io.inputChars ?? 0) > io.input!.length ? { inputCut: true } : {}) } : {}),
80
+ ...(output ? { output: output.text, ...(io.outputChars !== undefined ? { outputChars: io.outputChars } : {}), ...(output.cut || (io.outputChars ?? 0) > io.output!.length ? { outputCut: true } : {}) } : {}),
81
+ ...(io.truncated || input?.cut || output?.cut ? { truncated: true } : {}),
82
+ });
83
+ return true;
84
+ } catch (err) {
85
+ console.error(`[capture] ${tool} (${callId}) for ${runId}:`, err instanceof Error ? err.message : err);
86
+ return false;
87
+ }
88
+ }