@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
@@ -0,0 +1,5 @@
1
+ import type { RunSummary } from "./store.ts";
2
+ /** Agent name → the ISO time of its latest step that ran. */
3
+ export declare function agentLastRuns(runs: RunSummary[]): Map<string, string>;
4
+ /** Whole days since `iso`, or null for never. */
5
+ export declare function daysSince(iso: string | null | undefined, now?: number): number | null;
@@ -0,0 +1,31 @@
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
+ /** A step that started counts; one that was skipped or never reached does not. */
7
+ const RAN = new Set(["running", "completed", "failed", "awaiting-approval"]);
8
+ /** Agent name → the ISO time of its latest step that ran. */
9
+ export function agentLastRuns(runs) {
10
+ const last = new Map();
11
+ for (const run of runs) {
12
+ for (const step of run.steps ?? []) {
13
+ if (!step.agent || !RAN.has(step.status))
14
+ continue;
15
+ const at = step.startedAt ?? run.startedAt;
16
+ if (!at)
17
+ continue;
18
+ const prev = last.get(step.agent);
19
+ if (!prev || at > prev)
20
+ last.set(step.agent, at);
21
+ }
22
+ }
23
+ return last;
24
+ }
25
+ /** Whole days since `iso`, or null for never. */
26
+ export function daysSince(iso, now = Date.now()) {
27
+ if (!iso)
28
+ return null;
29
+ const t = Date.parse(iso);
30
+ return Number.isFinite(t) ? Math.floor((now - t) / 86_400_000) : null;
31
+ }
@@ -14,7 +14,7 @@
14
14
  // were about somebody else's desk.
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.js";
18
18
  import { assertSafeName, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, workspaceDir, workspaceTools, listWorkspaces, } from "./store.js";
19
19
  import { libraryDir, libraryTools } from "./library.js";
20
20
  import { isRuntimeTool, toolEntryName } from "./tool-names.js";
@@ -16,7 +16,7 @@
16
16
  import fs from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
- import matter from "gray-matter";
19
+ import matter from "./matter.js";
20
20
  import { z } from "zod";
21
21
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
22
22
  import { resolveModel, resolveEffort } from "./store.js";
@@ -8,7 +8,7 @@
8
8
  // else in the file is untouched. Null removes the key.
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
- import matter from "gray-matter";
11
+ import matter from "./matter.js";
12
12
  const FRONT = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n|$)/;
13
13
  /** The frontmatter of a directory's AGENTS.md (or project.md), or {}. */
14
14
  export function readAgentsMdKeys(dir) {
@@ -0,0 +1,60 @@
1
+ import type { Outcome } from "./outcome.ts";
2
+ export interface AlertConfig {
3
+ failureRate?: number;
4
+ minRuns: number;
5
+ creditsDay?: number;
6
+ runMinutes?: number;
7
+ staleDays?: number;
8
+ costSpike?: number;
9
+ runsPerHour?: number;
10
+ /** Told after a run (platform host-watch.ts), not on the watchdog's tick. */
11
+ newHost?: boolean;
12
+ }
13
+ /** The config, and what is wrong with it in words (for `foldrun check`). */
14
+ export declare function parseAlerts(raw: unknown): {
15
+ config: AlertConfig | null;
16
+ problems: string[];
17
+ };
18
+ export interface AlertFacts {
19
+ /** Outcomes of the runs started in the last 24 hours. */
20
+ outcomes: Outcome[];
21
+ /** Credits are cents: USD charged in the last 24 hours. */
22
+ chargedUsd: number;
23
+ /** Runs still going. */
24
+ live: {
25
+ id: string;
26
+ flow: string;
27
+ startedAt: string;
28
+ }[];
29
+ /** For stale_days: each agent, when it last ran (null: not in the runs
30
+ * kept), and since when it has existed — an agent added yesterday is not
31
+ * stale for never having run. */
32
+ agents?: {
33
+ name: string;
34
+ lastRunAt: string | null;
35
+ since: string;
36
+ }[];
37
+ /** For cost_spike and runs_per_hour: the runs of the last seven days,
38
+ * test runs left out. costUsd is what its steps cost. */
39
+ runs?: {
40
+ id: string;
41
+ flow: string;
42
+ startedAt: string;
43
+ finishedAt: string | null;
44
+ costUsd: number;
45
+ }[];
46
+ }
47
+ export interface Alert {
48
+ /** What the once-a-day memory is keyed by: the rule, or the rule and run. */
49
+ key: string;
50
+ headline: string;
51
+ detail: string;
52
+ runId?: string;
53
+ flow?: string;
54
+ /** Said once for this key, however long it stays true (a slow run, an
55
+ * idle spell); the watchdog's notice memory is pruned after a week, so
56
+ * a condition that lasts is said again weekly at most. */
57
+ once?: boolean;
58
+ }
59
+ /** What crosses its threshold now. */
60
+ export declare function evaluateAlerts(cfg: AlertConfig, facts: AlertFacts, workspace: string, now: number): Alert[];
@@ -0,0 +1,161 @@
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
+ const KEYS = ["failure_rate", "min_runs", "credits_day", "run_minutes", "stale_days", "cost_spike", "runs_per_hour", "new_host"];
18
+ const num = (v) => {
19
+ if (typeof v === "number" && Number.isFinite(v))
20
+ return v;
21
+ if (typeof v === "string" && v.trim() && Number.isFinite(Number(v.trim())))
22
+ return Number(v.trim());
23
+ return null;
24
+ };
25
+ /** The config, and what is wrong with it in words (for `foldrun check`). */
26
+ export function parseAlerts(raw) {
27
+ if (raw === undefined || raw === null)
28
+ return { config: null, problems: [] };
29
+ if (typeof raw !== "object" || Array.isArray(raw)) {
30
+ 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"] };
31
+ }
32
+ const o = raw;
33
+ const problems = [];
34
+ for (const k of Object.keys(o))
35
+ if (!KEYS.includes(k))
36
+ problems.push(`alerts: ${k} — not a threshold; the keys are ${KEYS.join(", ")}`);
37
+ const cfg = { minRuns: 5 };
38
+ if (o.failure_rate !== undefined) {
39
+ const s = String(o.failure_rate).trim();
40
+ const pct = /^(\d+(?:\.\d+)?)\s*%$/.exec(s);
41
+ const v = pct ? Number(pct[1]) / 100 : num(o.failure_rate);
42
+ if (v === null || v <= 0 || v > 1)
43
+ problems.push(`alerts: failure_rate ${s} — a share of runs, like 30% or 0.3`);
44
+ else
45
+ cfg.failureRate = v;
46
+ }
47
+ 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]]) {
48
+ if (o[key] === undefined)
49
+ continue;
50
+ const v = num(o[key]);
51
+ if (v === null || v < min)
52
+ problems.push(`alerts: ${key} ${String(o[key])} — a number, at least ${min}`);
53
+ else
54
+ cfg[field] = v;
55
+ }
56
+ if (o.new_host !== undefined) {
57
+ if (typeof o.new_host !== "boolean")
58
+ problems.push(`alerts: new_host ${String(o.new_host)} — true or false`);
59
+ else if (o.new_host)
60
+ cfg.newHost = true;
61
+ }
62
+ const any = cfg.newHost === true || cfg.failureRate !== undefined || cfg.creditsDay !== undefined || cfg.runMinutes !== undefined || cfg.staleDays !== undefined || cfg.costSpike !== undefined || cfg.runsPerHour !== undefined;
63
+ return { config: any ? cfg : null, problems };
64
+ }
65
+ /** How few earlier runs a flow needs before its average means anything. */
66
+ const SPIKE_MIN_RUNS = 3;
67
+ /** What crosses its threshold now. */
68
+ export function evaluateAlerts(cfg, facts, workspace, now) {
69
+ const out = [];
70
+ if (cfg.failureRate !== undefined) {
71
+ const finished = facts.outcomes.filter((o) => o === "failed" || o === "good" || o === "done" || o === "quiet" || o === "blocked" || o === "bad");
72
+ const failed = finished.filter((o) => o === "failed").length;
73
+ if (finished.length >= cfg.minRuns && failed / finished.length >= cfg.failureRate) {
74
+ out.push({
75
+ key: "failure_rate",
76
+ headline: `⚠ ${workspace}: ${failed} of its last ${finished.length} runs failed`,
77
+ 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.`,
78
+ });
79
+ }
80
+ }
81
+ if (cfg.creditsDay !== undefined) {
82
+ const credits = Math.round(facts.chargedUsd * 100);
83
+ if (credits >= cfg.creditsDay) {
84
+ out.push({
85
+ key: "credits_day",
86
+ headline: `⚠ ${workspace} was charged ${credits.toLocaleString("en")} credits in a day`,
87
+ detail: `Over the ${cfg.creditsDay.toLocaleString("en")} in alerts: credits_day, in the last 24 hours. Observe shows which flows and agents.`,
88
+ });
89
+ }
90
+ }
91
+ if (cfg.runMinutes !== undefined) {
92
+ for (const r of facts.live) {
93
+ const mins = (now - Date.parse(r.startedAt)) / 60_000;
94
+ if (mins >= cfg.runMinutes) {
95
+ out.push({
96
+ key: `run_minutes/${r.id}`,
97
+ runId: r.id,
98
+ flow: r.flow,
99
+ headline: `⚠ ${workspace}: ${r.flow} has been running ${Math.round(mins)} minutes`,
100
+ 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.`,
101
+ });
102
+ }
103
+ }
104
+ }
105
+ if (cfg.costSpike !== undefined && facts.runs) {
106
+ // A run that finished in the last day, against the runs of its flow that
107
+ // finished before it in the week: once per run.
108
+ const done = facts.runs.filter((r) => r.finishedAt).sort((a, b) => a.finishedAt.localeCompare(b.finishedAt));
109
+ for (const r of done) {
110
+ if (now - Date.parse(r.finishedAt) > 86_400_000)
111
+ continue;
112
+ const before = done.filter((o) => o.flow === r.flow && o.id !== r.id && o.finishedAt < r.finishedAt);
113
+ if (before.length < SPIKE_MIN_RUNS)
114
+ continue;
115
+ const mean = before.reduce((n, o) => n + o.costUsd, 0) / before.length;
116
+ if (mean <= 0 || r.costUsd < cfg.costSpike * mean)
117
+ continue;
118
+ out.push({
119
+ key: `cost_spike/${r.id}`,
120
+ runId: r.id,
121
+ flow: r.flow,
122
+ headline: `${workspace}: a ${r.flow} run cost ${(r.costUsd / mean).toFixed(1)}× its usual`,
123
+ 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.`,
124
+ });
125
+ }
126
+ }
127
+ if (cfg.runsPerHour !== undefined && facts.runs) {
128
+ const hour = facts.runs.filter((r) => now - Date.parse(r.startedAt) <= 3_600_000);
129
+ const byFlow = new Map();
130
+ for (const r of hour)
131
+ byFlow.set(r.flow, (byFlow.get(r.flow) ?? 0) + 1);
132
+ for (const [flow, n] of byFlow) {
133
+ if (n < cfg.runsPerHour)
134
+ continue;
135
+ out.push({
136
+ key: `runs_per_hour/${flow}`,
137
+ flow,
138
+ headline: `${workspace}: ${flow} started ${n} times in an hour`,
139
+ 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.`,
140
+ });
141
+ }
142
+ }
143
+ if (cfg.staleDays !== undefined) {
144
+ for (const a of facts.agents ?? []) {
145
+ const from = Date.parse(a.lastRunAt ?? a.since);
146
+ if (!Number.isFinite(from))
147
+ continue;
148
+ const days = Math.floor((now - from) / 86_400_000);
149
+ if (days < cfg.staleDays)
150
+ continue;
151
+ out.push({
152
+ // Keyed by the run it last made, so a run and a new idle spell are news again.
153
+ key: `stale_days/${a.name}/${a.lastRunAt ?? "never"}`,
154
+ once: true,
155
+ headline: `${workspace}: agent ${a.name} has not run in ${days} days`,
156
+ 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.`,
157
+ });
158
+ }
159
+ }
160
+ return out;
161
+ }
@@ -169,6 +169,10 @@ export function classifyCall(tool, input, ctx = {}) {
169
169
  return { write: false };
170
170
  if (server === "foldrun_ask")
171
171
  return write("a question to a person");
172
+ // tools: [peers] — reading the roster changes nothing; a message to a
173
+ // colleague is said, and a re-run would say it twice.
174
+ if (server === "foldrun_peers")
175
+ return name === "peers_list" ? { write: false } : write("a message to a colleague");
172
176
  if (server === "foldrun_scripts") {
173
177
  if (name !== "web")
174
178
  return write(`script ${name}`);
@@ -0,0 +1,23 @@
1
+ import type { CallIo } from "./step-exec.ts";
2
+ /** How much past the cap a step sends, so masking sees a secret whole
3
+ * before the cut (a value longer than this is a file secret, which is also
4
+ * masked line by line). */
5
+ export declare const CAPTURE_SLACK = 4096;
6
+ /**
7
+ * What the step's scrub replaces: every secret value it holds → its name,
8
+ * longest first. An `@file` secret (a key, a certificate) by its content, not
9
+ * its stored form; a multi-line value also line by line, because a file is
10
+ * read back a line at a time (the Read tool numbers each); and every value
11
+ * also as it appears inside JSON, where `"` `\` and newlines are escaped — a
12
+ * tool's input is JSON. Values under 8 characters are skipped: a two-letter
13
+ * secret would redact half the language, and anything that short is not one.
14
+ */
15
+ export declare function redactionsFor(secrets: Record<string, string | undefined>): [string, string][];
16
+ /** Masked, then cut to the cap. */
17
+ export declare function maskCall(text: string, redactSecrets: (text: string) => string): {
18
+ text: string;
19
+ cut: boolean;
20
+ };
21
+ export declare function recordCallIo(workspaceRoot: string, runId: string, callId: string, tool: string, io: CallIo,
22
+ /** The step's own scrub: its resolved secret values → `[redacted:NAME]`. */
23
+ redactSecrets: (text: string) => string): boolean;
@@ -0,0 +1,80 @@
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
+ import { redactText } from "./scan.js";
8
+ import { writeCapturedCall, CAPTURE_CAP } from "./store.js";
9
+ import { isFileValue, fileContent } from "./secrets.js";
10
+ /** How much past the cap a step sends, so masking sees a secret whole
11
+ * before the cut (a value longer than this is a file secret, which is also
12
+ * masked line by line). */
13
+ export const CAPTURE_SLACK = 4096;
14
+ /**
15
+ * What the step's scrub replaces: every secret value it holds → its name,
16
+ * longest first. An `@file` secret (a key, a certificate) by its content, not
17
+ * its stored form; a multi-line value also line by line, because a file is
18
+ * read back a line at a time (the Read tool numbers each); and every value
19
+ * also as it appears inside JSON, where `"` `\` and newlines are escaped — a
20
+ * tool's input is JSON. Values under 8 characters are skipped: a two-letter
21
+ * secret would redact half the language, and anything that short is not one.
22
+ */
23
+ export function redactionsFor(secrets) {
24
+ const out = new Map();
25
+ const add = (v, name) => {
26
+ if (v.length >= 8 && !out.has(v))
27
+ out.set(v, name);
28
+ };
29
+ for (const [name, raw] of Object.entries(secrets)) {
30
+ if (typeof raw !== "string")
31
+ continue;
32
+ for (const v of isFileValue(raw) ? [raw, fileContent(raw)] : [raw]) {
33
+ add(v, name);
34
+ add(v.trim(), name);
35
+ const escaped = JSON.stringify(v).slice(1, -1);
36
+ if (escaped !== v)
37
+ add(escaped, name);
38
+ if (/\r?\n/.test(v.trim())) {
39
+ for (const line of v.split(/\r?\n/)) {
40
+ const l = line.trim();
41
+ if (l.length >= 16)
42
+ add(l, name);
43
+ }
44
+ }
45
+ }
46
+ }
47
+ return [...out.entries()].sort((a, b) => b[0].length - a[0].length);
48
+ }
49
+ // A private key's whole block — scan.ts names a key header and leaves the
50
+ // body, which is right for a finding and wrong for a stored output.
51
+ // Unterminated too: a key cut short, or printed without its END line, is
52
+ // masked to the end of the text.
53
+ const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$(?![\s\S]))/g;
54
+ /** Masked, then cut to the cap. */
55
+ export function maskCall(text, redactSecrets) {
56
+ const masked = redactText(redactSecrets(text)).replace(PRIVATE_KEY, "[redacted:private key]");
57
+ return masked.length > CAPTURE_CAP ? { text: masked.slice(0, CAPTURE_CAP), cut: true } : { text: masked, cut: false };
58
+ }
59
+ export function recordCallIo(workspaceRoot, runId, callId, tool, io,
60
+ /** The step's own scrub: its resolved secret values → `[redacted:NAME]`. */
61
+ redactSecrets) {
62
+ try {
63
+ const input = io.input === undefined ? null : maskCall(io.input, redactSecrets);
64
+ const output = io.output === undefined ? null : maskCall(io.output, redactSecrets);
65
+ writeCapturedCall(workspaceRoot, runId, {
66
+ id: callId,
67
+ tool,
68
+ // Which part was cut, each on its own: in the sandbox (the whole was
69
+ // longer than what was sent) or here, after masking.
70
+ ...(input ? { input: input.text, ...(io.inputChars !== undefined ? { inputChars: io.inputChars } : {}), ...(input.cut || (io.inputChars ?? 0) > io.input.length ? { inputCut: true } : {}) } : {}),
71
+ ...(output ? { output: output.text, ...(io.outputChars !== undefined ? { outputChars: io.outputChars } : {}), ...(output.cut || (io.outputChars ?? 0) > io.output.length ? { outputCut: true } : {}) } : {}),
72
+ ...(io.truncated || input?.cut || output?.cut ? { truncated: true } : {}),
73
+ });
74
+ return true;
75
+ }
76
+ catch (err) {
77
+ console.error(`[capture] ${tool} (${callId}) for ${runId}:`, err instanceof Error ? err.message : err);
78
+ return false;
79
+ }
80
+ }
@@ -0,0 +1,57 @@
1
+ import type { RunRecord } from "./store.ts";
2
+ export interface StepSide {
3
+ status: string;
4
+ instruction: string;
5
+ model?: string;
6
+ secs: number | null;
7
+ tokensIn: number;
8
+ tokensOut: number;
9
+ modelCalls: number;
10
+ /** Tool name → how many calls. */
11
+ tools: Record<string, number>;
12
+ toolErrors: number;
13
+ /** The first errors it wrote, each one line. */
14
+ errors: string[];
15
+ /** The first line of what it concluded. */
16
+ said: string | null;
17
+ }
18
+ export interface StepDiff {
19
+ /** 1-based, as the trace numbers steps. */
20
+ step: number;
21
+ agent: string;
22
+ base: StepSide | null;
23
+ run: StepSide | null;
24
+ /** What differs, in words, most telling first; [] when nothing does. */
25
+ changes: string[];
26
+ }
27
+ export interface RunComparison {
28
+ base: {
29
+ id: string;
30
+ status: string;
31
+ startedAt: string;
32
+ revision: string | null;
33
+ };
34
+ run: {
35
+ id: string;
36
+ status: string;
37
+ startedAt: string;
38
+ revision: string | null;
39
+ };
40
+ revisionChanged: boolean;
41
+ /** Files changed between the two revisions; null when that cannot be said. */
42
+ changedFiles: string[] | null;
43
+ steps: StepDiff[];
44
+ }
45
+ export declare function compareRuns(base: RunRecord, run: RunRecord, changedFiles?: string[] | null): RunComparison;
46
+ /** The run to compare a run with when none is named: the newest earlier run
47
+ * of the same flow that finished well (completed, not BLOCKED or BAD), or
48
+ * null. `outcomeOf` is the caller's, so this stays free of the verdict reader. */
49
+ export declare function lastGoodBefore<R extends {
50
+ id: string;
51
+ flow: string;
52
+ startedAt: string;
53
+ }>(runs: R[], run: {
54
+ id: string;
55
+ flow: string;
56
+ startedAt: string;
57
+ }, good: (r: R) => boolean): R | null;
@@ -0,0 +1,116 @@
1
+ // Two runs of one flow side by side (FOL-72 step 6): "why did this get
2
+ // worse" read off the records — which files changed between the revisions
3
+ // they ran, and step by step what differs: status, the instruction, the
4
+ // model, time, tokens, model calls, the tools it used and the errors it hit.
5
+ // Pure; the caller hands in the changed files (history.ts changedPathsBetween).
6
+ const firstLine = (s, n = 200) => {
7
+ const l = (s ?? "").split("\n").map((x) => x.trim()).find(Boolean);
8
+ return l ? l.slice(0, n) : null;
9
+ };
10
+ function side(s) {
11
+ const tools = {};
12
+ let toolErrors = 0;
13
+ for (const e of s.events) {
14
+ if (e.type !== "tool")
15
+ continue;
16
+ // A pair is one call: count the call, and its completion's error.
17
+ if (e.ms == null)
18
+ tools[e.text] = (tools[e.text] ?? 0) + 1;
19
+ else if (e.err)
20
+ toolErrors++;
21
+ }
22
+ const start = s.startedAt ? Date.parse(s.startedAt) : NaN;
23
+ const end = s.finishedAt ? Date.parse(s.finishedAt) : NaN;
24
+ return {
25
+ status: s.status,
26
+ instruction: s.instruction,
27
+ ...(s.model ? { model: s.model } : {}),
28
+ secs: Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1000)) : null,
29
+ tokensIn: s.tokens?.input ?? 0,
30
+ tokensOut: s.tokens?.output ?? 0,
31
+ modelCalls: s.calls?.length ?? s.turns ?? 0,
32
+ tools,
33
+ toolErrors,
34
+ errors: s.events.filter((e) => e.type === "error").slice(0, 3).map((e) => firstLine(e.text) ?? ""),
35
+ said: firstLine(s.conclusion ?? s.result),
36
+ };
37
+ }
38
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
39
+ /** Much more or much less: by a quarter and by a real amount. */
40
+ const moved = (a, b, floor) => Math.abs(b - a) >= floor && (a === 0 ? b > 0 : Math.abs(b - a) / a >= 0.25);
41
+ function changes(a, b) {
42
+ if (!a && b)
43
+ return ["new step: not in the base run"];
44
+ if (a && !b)
45
+ return ["not in this run"];
46
+ if (!a || !b)
47
+ return [];
48
+ const out = [];
49
+ if (a.status !== b.status)
50
+ out.push(`status: ${a.status} → ${b.status}`);
51
+ const newErrors = b.errors.filter((e) => !a.errors.includes(e));
52
+ if (newErrors.length)
53
+ out.push(`new error: ${newErrors[0]}`);
54
+ if (a.instruction.trim() !== b.instruction.trim())
55
+ out.push("instruction changed");
56
+ if ((a.model ?? "") !== (b.model ?? ""))
57
+ out.push(`model: ${a.model ?? "default"} → ${b.model ?? "default"}`);
58
+ if (b.toolErrors > a.toolErrors)
59
+ out.push(`tool errors: ${a.toolErrors} → ${b.toolErrors}`);
60
+ const names = new Set([...Object.keys(a.tools), ...Object.keys(b.tools)]);
61
+ const toolDiff = [...names]
62
+ .map((n) => ({ n, d: (b.tools[n] ?? 0) - (a.tools[n] ?? 0) }))
63
+ .filter((x) => x.d !== 0)
64
+ .sort((x, y) => Math.abs(y.d) - Math.abs(x.d))
65
+ .slice(0, 4)
66
+ .map((x) => `${x.d > 0 ? "+" : "−"}${x.n} ×${Math.abs(x.d)}`);
67
+ if (toolDiff.length)
68
+ out.push(`tools: ${toolDiff.join(", ")}`);
69
+ if (a.secs !== null && b.secs !== null && moved(a.secs, b.secs, 10))
70
+ out.push(`took ${a.secs}s → ${b.secs}s`);
71
+ if (moved(a.tokensIn, b.tokensIn, 5000))
72
+ out.push(`tokens in ${k(a.tokensIn)} → ${k(b.tokensIn)}`);
73
+ if (moved(a.modelCalls, b.modelCalls, 3))
74
+ out.push(`model calls ${a.modelCalls} → ${b.modelCalls}`);
75
+ if (a.said !== b.said && b.said)
76
+ out.push(`said: ${b.said}`);
77
+ return out;
78
+ }
79
+ /** Steps lined up: the flow's own steps by position where the agents agree,
80
+ * fan-out instances by agent and order. */
81
+ function align(a, b) {
82
+ const pairs = [];
83
+ const used = new Set();
84
+ b.forEach((s, i) => {
85
+ let j = a[i] && a[i].agent === s.agent && !used.has(i) ? i : -1;
86
+ if (j < 0)
87
+ j = a.findIndex((x, m) => !used.has(m) && x.agent === s.agent);
88
+ if (j >= 0)
89
+ used.add(j);
90
+ pairs.push([j >= 0 ? a[j] : null, s]);
91
+ });
92
+ a.forEach((s, m) => !used.has(m) && pairs.push([s, null]));
93
+ return pairs;
94
+ }
95
+ export function compareRuns(base, run, changedFiles = null) {
96
+ const head = (r) => ({ id: r.id, status: r.status, startedAt: r.startedAt, revision: r.revision ?? null });
97
+ return {
98
+ base: head(base),
99
+ run: head(run),
100
+ revisionChanged: (base.revision ?? null) !== (run.revision ?? null),
101
+ changedFiles,
102
+ steps: align(base.steps, run.steps).map(([a, b], i) => {
103
+ const sa = a ? side(a) : null;
104
+ const sb = b ? side(b) : null;
105
+ return { step: b ? run.steps.indexOf(b) + 1 : i + 1, agent: (b ?? a).agent, base: sa, run: sb, changes: changes(sa, sb) };
106
+ }),
107
+ };
108
+ }
109
+ /** The run to compare a run with when none is named: the newest earlier run
110
+ * of the same flow that finished well (completed, not BLOCKED or BAD), or
111
+ * null. `outcomeOf` is the caller's, so this stays free of the verdict reader. */
112
+ export function lastGoodBefore(runs, run, good) {
113
+ return (runs
114
+ .filter((r) => r.flow === run.flow && r.id !== run.id && r.startedAt < run.startedAt && good(r))
115
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0] ?? null);
116
+ }
@@ -37,6 +37,7 @@ const TOOL_GROUPS = [
37
37
  { label: "code", hint: "Bash — runs anything in the sandbox" },
38
38
  { label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
39
39
  { label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
40
+ { label: "peers", hint: "peers_list + peers_message — the colleagues running beside it in its parallel group, and a message to one of them" },
40
41
  { label: "install", hint: "install apt, pip or npm packages mid-step — every agent has it; disallowedTools: [install] removes it" },
41
42
  ];
42
43
  const SDK_TOOL_NAMES = [
@@ -47,7 +47,27 @@ export interface DeployResult extends DeployPlan {
47
47
  * other things is the normal case, not an error.
48
48
  */
49
49
  export declare const SUBAGENT_IMPORT_DIRS: readonly [".agents/agents", ".claude/agents"];
50
- export declare function readTree(dir: string): DeployFile[];
50
+ /** Bounds for a reader that only looks at the text (the checks, the
51
+ * assistant): on 20 Oct 2026 the daily rescan read one desk's 1.2 GB of
52
+ * state/ into a 2 GB heap and crash-looped the worker. A deploy sets none. */
53
+ export interface ReadTreeOptions {
54
+ /** Data (DATA_PATH) larger than this is left out, unread. */
55
+ maxDataFileBytes?: number;
56
+ /** Data past this many bytes in all, or this many files, is left out. */
57
+ maxDataBytes?: number;
58
+ maxDataFiles?: number;
59
+ /** Everything else past this many bytes in all: readTree throws
60
+ * TreeTooLarge rather than return a part of the definitions, which a
61
+ * check would read as missing agents and flows. */
62
+ maxSourceBytes?: number;
63
+ /** Each data file left out, and its size. */
64
+ onSkip?: (path: string, bytes: number) => void;
65
+ }
66
+ export declare class TreeTooLarge extends Error {
67
+ /** What an HTTP route answers with it. */
68
+ readonly status = 413;
69
+ }
70
+ export declare function readTree(dir: string, opts?: ReadTreeOptions): DeployFile[];
51
71
  /**
52
72
  * Everything wrong with an incoming workspace, before any of it is live.
53
73
  *
@@ -77,6 +97,21 @@ export declare function unopenedFrontmatter(raw: string): string | null;
77
97
  * advice (an agent that could leak, an unpinned package) is for `foldrun
78
98
  * check` and the Checks page, not for every deploy's output.
79
99
  */
100
+ /**
101
+ * What `deploy_block:` in AGENTS.md refuses (FOL-58). `none`, the default:
102
+ * the scanner's findings are warnings and the deploy goes ahead, as it always
103
+ * did. `secrets`: a key or password written into a file is refused.
104
+ * `security`: every security finding is — secrets, hidden or injected text,
105
+ * the lethal trifecta, pipe-to-shell, an unpinned MCP server, a step that
106
+ * acts outside with no check or approval. A
107
+ * `foldrun-ignore` with a reason still accepts one.
108
+ */
109
+ export declare const DEPLOY_BLOCK_MODES: readonly ["none", "secrets", "security"];
110
+ export type DeployBlockMode = (typeof DEPLOY_BLOCK_MODES)[number];
111
+ /** The workspace's own AGENTS.md, else the account's, else none. */
112
+ export declare function deployBlockMode(files: DeployFile[], accountKeys: Record<string, unknown>): DeployBlockMode;
113
+ /** The findings `mode` turns into refusals, as deploy issues. */
114
+ export declare function deployBlockers(files: DeployFile[], mode: DeployBlockMode, options?: ScanOptions): DeployIssue[];
80
115
  export declare function deployWarnings(files: DeployFile[]): DeployIssue[];
81
116
  /**
82
117
  * Everything the platform can say about a workspace's files, as findings