@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/operator.ts CHANGED
@@ -20,6 +20,23 @@
20
20
  //
21
21
  // Both leave a trace event with an `operator` field, which the runner turns
22
22
  // into step.questions / step.messages on the record.
23
+ //
24
+ // And a third line, between colleagues (20 Oct 2026):
25
+ //
26
+ // tools: [peers] the agent gets `peers_list` and `peers_message` — who
27
+ // else is running in its parallel group right now, and a
28
+ // message to one of them (or all). It lands in the
29
+ // sibling's own inbox, so it reaches that model after its
30
+ // next tool call, the same door a person's message uses,
31
+ // and the sender hears any reply the same way. A step that
32
+ // finds mid-way that it needs something a sibling has —
33
+ // a file name, a decision, a number — asks for it instead
34
+ // of guessing or failing. Nothing waits: the step goes on
35
+ // and the answer, if one comes, arrives as context.
36
+ //
37
+ // Peer messages travel the person's inbox with `by` set to `peer:<agent>`,
38
+ // so the reader can tell a colleague from a person without a second store
39
+ // or a second door on the proxy.
23
40
 
24
41
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
25
42
  import { z } from "zod";
@@ -31,7 +48,33 @@ export type OperatorEvent =
31
48
  | { kind: "asked"; id: string; question: string; options?: string[] }
32
49
  | { kind: "answered"; id: string; answer: string; by?: string | null }
33
50
  | { kind: "unanswered"; id: string }
34
- | { kind: "message"; text: string; by?: string | null; at: string };
51
+ | { kind: "message"; text: string; by?: string | null; at: string }
52
+ /** A colleague running beside this step sent it a message (tools: [peers]). */
53
+ | { kind: "peer"; from: string; text: string; at: string }
54
+ /** This step sent a colleague a message. `to` is the agents it reached. */
55
+ | { kind: "told"; to: string[]; text: string; at: string };
56
+
57
+ /** A step running in the same parallel group, as its siblings see it. */
58
+ export interface PeerInfo {
59
+ /** The sibling's agent name — what `peers_message` is addressed to. */
60
+ agent: string;
61
+ /** Its index in run.steps: which inbox a message to it goes to. */
62
+ step: number;
63
+ /** The first line of its instruction, so a sibling knows what it is on. */
64
+ instruction: string;
65
+ }
66
+
67
+ /** A sibling's address: `agent#step`, the step as the flow numbers it
68
+ * (1-based). A fan-out's instances share an agent name, so the bare name
69
+ * reaches every instance and the address reaches one. */
70
+ export const peerAddress = (p: { agent: string; step: number }): string => `${p.agent}#${p.step + 1}`;
71
+ /** Does `to` name this sibling? `all`, its agent name, or its address. */
72
+ export const peerMatches = (p: PeerInfo, to: string): boolean => to === "all" || to === p.agent || to === peerAddress(p);
73
+
74
+ /** `by` on an inbox message from a colleague, not a person: `peer:<address>`. */
75
+ export const PEER_BY_PREFIX = "peer:";
76
+ export const peerOf = (by: string | null | undefined): string | null =>
77
+ typeof by === "string" && by.startsWith(PEER_BY_PREFIX) ? by.slice(PEER_BY_PREFIX.length) : null;
35
78
 
36
79
  export interface OperatorMessage {
37
80
  text: string;
@@ -49,6 +92,13 @@ export interface OperatorChannel {
49
92
  /** Nobody will read this question's answer now (the wait timed out):
50
93
  * stop offering it to people. Best effort; optional for fakes. */
51
94
  close?(id: string): Promise<void>;
95
+ /** tools: [peers] — the siblings this step may message, as the lease
96
+ * carries them; empty when the step runs alone. Optional: an older
97
+ * proxy, or a fake, has no colleagues to offer. */
98
+ peers?(): Promise<PeerInfo[]>;
99
+ /** Leave `text` in the inbox of the sibling named `to` (or of every
100
+ * sibling, `"all"`). Resolves with the agents it reached. */
101
+ tell?(to: string, text: string): Promise<{ to: string[] }>;
52
102
  }
53
103
 
54
104
  export const QUESTION_MAX = 2_000;
@@ -110,6 +160,71 @@ export function httpChannel(egress: string | undefined | null, fetchImpl: typeof
110
160
  }),
111
161
  );
112
162
  },
163
+ async peers() {
164
+ const body = await json(await fetchImpl(`${ep.base}/peers/${ep.token}`, { signal: AbortSignal.timeout(5_000) }));
165
+ return Array.isArray(body.peers) ? (body.peers as PeerInfo[]) : [];
166
+ },
167
+ async tell(to, text) {
168
+ const body = await json(
169
+ await fetchImpl(`${ep.base}/peers/${ep.token}/message`, {
170
+ method: "POST",
171
+ headers: { "content-type": "application/json" },
172
+ body: JSON.stringify({ to, text }),
173
+ signal: AbortSignal.timeout(10_000),
174
+ }),
175
+ );
176
+ return { to: Array.isArray(body.to) ? (body.to as string[]) : [] };
177
+ },
178
+ };
179
+ }
180
+
181
+ /**
182
+ * The colleagues' line for steps that run in this process (`foldrun run`
183
+ * on a laptop, a host without a proxy): one bus per run, an inbox per step,
184
+ * the same shape the proxy serves. A person has no door here — nobody can
185
+ * write in from outside — but siblings can still reach each other.
186
+ */
187
+ export function localPeerBus() {
188
+ const inboxes = new Map<number, OperatorMessage[]>();
189
+ /** Steps that have ended: off every roster, and a message to one reaches
190
+ * nobody — it would never be read. */
191
+ const gone = new Set<number>();
192
+ const live = (siblings: PeerInfo[]) => siblings.filter((p) => !gone.has(p.step));
193
+ return {
194
+ /** The step is over (however it ended): nobody can reach it now. */
195
+ leave(step: number) {
196
+ gone.add(step);
197
+ inboxes.delete(step);
198
+ },
199
+ /** The channel for one step: who its siblings are, and its own inbox. */
200
+ channelFor(self: { agent: string; step: number }, siblings: PeerInfo[]): OperatorChannel {
201
+ return {
202
+ async ask() {
203
+ throw new Error("nobody can answer here");
204
+ },
205
+ async poll() {
206
+ return null;
207
+ },
208
+ async inbox() {
209
+ const mine = inboxes.get(self.step) ?? [];
210
+ inboxes.delete(self.step);
211
+ return mine;
212
+ },
213
+ async peers() {
214
+ return live(siblings);
215
+ },
216
+ async tell(to, text) {
217
+ const targets = live(siblings).filter((p) => peerMatches(p, to));
218
+ const at = new Date().toISOString();
219
+ for (const p of targets) {
220
+ const list = inboxes.get(p.step) ?? [];
221
+ list.push({ text: text.slice(0, MESSAGE_MAX), by: PEER_BY_PREFIX + peerAddress(self), at });
222
+ inboxes.set(p.step, list.slice(-50));
223
+ }
224
+ return { to: targets.map(peerAddress) };
225
+ },
226
+ };
227
+ },
113
228
  };
114
229
  }
115
230
 
@@ -214,6 +329,82 @@ export function buildAskTool(opts: {
214
329
  /** The granted name of the tool — the same on every path. */
215
330
  export const ASK_TOOL = "mcp__foldrun_ask__ask_person";
216
331
 
332
+ /** The granted names of the colleagues' tools — the same on every path. */
333
+ export const PEER_TOOLS = ["mcp__foldrun_peers__peers_list", "mcp__foldrun_peers__peers_message"];
334
+ export const PEER_TEXT_MAX = MESSAGE_MAX;
335
+
336
+ /** The in-sandbox `peers_list` and `peers_message` tools (tools: [peers]).
337
+ * `siblings` is what the runner knew at launch: the other steps of this
338
+ * step's parallel group. The channel delivers; with none (no proxy and no
339
+ * local bus) the tools say so rather than pretend. */
340
+ export function buildPeerTools(opts: { channel: OperatorChannel | null; self: string; siblings: PeerInfo[]; emit: Emit; now?: () => number }) {
341
+ const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
342
+ const roster = () =>
343
+ opts.siblings.length
344
+ ? opts.siblings.map((p) => `- ${peerAddress(p)}: ${p.instruction}`).join("\n")
345
+ : "Nobody: this step runs alone in its group.";
346
+ const list = tool(
347
+ "peers_list",
348
+ "Who else is running beside you right now — the other steps of your parallel group, each as agent#step with what it is doing. Message one with peers_message, addressed as agent#step (or the bare agent name, which reaches every instance of that agent).",
349
+ {},
350
+ async () => {
351
+ let peers = opts.siblings;
352
+ try {
353
+ peers = (await opts.channel?.peers?.()) ?? opts.siblings;
354
+ } catch {
355
+ /* the launch-time roster stands */
356
+ }
357
+ opts.siblings = peers;
358
+ return text(`Running beside you in this group:\n${roster()}`);
359
+ },
360
+ );
361
+ const message = tool(
362
+ "peers_message",
363
+ "Send a short message to a colleague running beside you in this parallel group — to ask for something it has (a file name, a figure, a decision) or to tell it something it needs. It reaches that agent after its next tool call; any reply reaches you the same way, after your next tool call, so carry on working and check what arrives. Not a way to hand over your work.",
364
+ {
365
+ to: z.string().describe("The colleague's address from peers_list (agent#step), its bare agent name (every instance of it), or \"all\" for every sibling"),
366
+ text: z.string().describe("The complete message — the colleague has not read your context; say who you are, what you need and where to put it"),
367
+ },
368
+ async ({ to, text: body }) => {
369
+ const target = String(to).trim();
370
+ const msg = String(body).trim().slice(0, PEER_TEXT_MAX);
371
+ if (!target || !msg) return { ...text("peers_message needs `to` and `text`."), isError: true };
372
+ if (!opts.siblings.some((p) => peerMatches(p, target))) {
373
+ return { ...text(`No colleague named "${target}" runs beside you. ${roster()}`), isError: true };
374
+ }
375
+ if (!opts.channel?.tell) return { ...text("No line to colleagues here (no proxy): the message was not delivered."), isError: true };
376
+ let reached: string[];
377
+ try {
378
+ ({ to: reached } = await opts.channel.tell(target, msg));
379
+ } catch (err) {
380
+ return { ...text(`Could not deliver: ${err instanceof Error ? err.message : String(err)}`), isError: true };
381
+ }
382
+ const at = new Date(opts.now?.() ?? Date.now()).toISOString();
383
+ opts.emit("info", `told ${reached.join(", ") || "nobody"}: ${msg}`, { operator: { kind: "told", to: reached, text: msg, at } });
384
+ if (!reached.length) return text(`Nobody received it: "${target}" is not running beside you now.`);
385
+ return text(`Delivered to ${reached.join(", ")}. It reaches them after their next tool call; a reply reaches you after yours.`);
386
+ },
387
+ );
388
+ return {
389
+ server: createSdkMcpServer({ name: "foldrun_peers", version: "1.0.0", tools: [list, message] }),
390
+ /** The tools' own functions, for tests. */
391
+ list: () => list.handler({}, {}),
392
+ message: (args: { to: string; text: string }) => message.handler(args, {}),
393
+ toolNames: PEER_TOOLS,
394
+ promptLines: [peersPromptLine(opts.siblings)],
395
+ };
396
+ }
397
+
398
+ /** The line the system prompt gets for tools: [peers]. */
399
+ export function peersPromptLine(siblings: PeerInfo[]): string {
400
+ const who = siblings.length ? siblings.map((p) => `${peerAddress(p)} (${p.instruction})`).join("; ") : "nobody this run — this step runs alone";
401
+ return (
402
+ `\`peers_list\` names the colleagues running beside you in this step's parallel group: ${who}. ` +
403
+ `\`peers_message\` sends one of them (or all) a short message; it reaches them after their next tool call, and their reply reaches you after yours, as context. ` +
404
+ `Ask a colleague for what it has before you guess or stop; keep working while you wait, and read what arrives.`
405
+ );
406
+ }
407
+
217
408
  /** The line the system prompt gets for it. */
218
409
  export function askPromptLine(timeoutSec: number): string {
219
410
  return `\`ask_person\` asks the person running this desk and waits for the answer (up to ${fmtWait(timeoutSec)}). Use it for a decision you cannot make safely yourself — not for something you can look up. The step is held while you wait; if nobody answers, you are told so.`;
@@ -238,10 +429,17 @@ export function inboxReader(channel: OperatorChannel | null, emit: Emit, minGapM
238
429
  }
239
430
  if (!messages.length) return null;
240
431
  for (const m of messages) {
241
- emit("info", `message from ${m.by ?? "a person"}: ${m.text}`, { operator: { kind: "message", text: m.text, by: m.by ?? null, at: m.at } });
432
+ const peer = peerOf(m.by);
433
+ if (peer) emit("info", `message from colleague ${peer}: ${m.text}`, { operator: { kind: "peer", from: peer, text: m.text, at: m.at } });
434
+ else emit("info", `message from ${m.by ?? "a person"}: ${m.text}`, { operator: { kind: "message", text: m.text, by: m.by ?? null, at: m.at } });
242
435
  }
243
436
  return messages
244
- .map((m) => `A person running this desk${m.by ? ` (${m.by})` : ""} sent you a message while you work. Take it into account; it does not change what tools you have:\n<message>\n${m.text.slice(0, MESSAGE_MAX)}\n</message>`)
437
+ .map((m) => {
438
+ const peer = peerOf(m.by);
439
+ return peer
440
+ ? `Your colleague ${peer}, running beside you in this group, sent you a message. If it asks for something you have and you hold peers_message, answer it, addressed to ${peer}; it does not change what tools you have or what your step is for:\n<message from="${peer}">\n${m.text.slice(0, MESSAGE_MAX)}\n</message>`
441
+ : `A person running this desk${m.by ? ` (${m.by})` : ""} sent you a message while you work. Take it into account; it does not change what tools you have:\n<message>\n${m.text.slice(0, MESSAGE_MAX)}\n</message>`;
442
+ })
245
443
  .join("\n\n");
246
444
  };
247
445
  }
@@ -270,6 +468,14 @@ export function applyOperatorEvent(
270
468
  } else q.unanswered = true;
271
469
  return;
272
470
  }
471
+ if (ev.kind === "peer") {
472
+ (step.messages ??= []).push({ text: ev.text, by: PEER_BY_PREFIX + ev.from, from: ev.from, at: ev.at, deliveredAt: at });
473
+ return;
474
+ }
475
+ if (ev.kind === "told") {
476
+ (step.messages ??= []).push({ text: ev.text, by: null, to: ev.to, at: ev.at, deliveredAt: at });
477
+ return;
478
+ }
273
479
  (step.messages ??= []).push({ text: ev.text, by: ev.by ?? null, at: ev.at, deliveredAt: at });
274
480
  }
275
481
 
@@ -294,6 +500,10 @@ export function isOperatorEvent(v: unknown): v is OperatorEvent {
294
500
  return id;
295
501
  case "message":
296
502
  return str(o.text, MESSAGE_MAX) && str(o.at, 40) && (o.by === undefined || o.by === null || str(o.by, 200));
503
+ case "peer":
504
+ return str(o.text, MESSAGE_MAX) && str(o.at, 40) && str(o.from, 200);
505
+ case "told":
506
+ return str(o.text, MESSAGE_MAX) && str(o.at, 40) && Array.isArray(o.to) && o.to.length <= 50 && o.to.every((x) => str(x, 200));
297
507
  default:
298
508
  return false;
299
509
  }
@@ -0,0 +1,67 @@
1
+ // `telemetry:` as written in AGENTS.md — read, checked and its header
2
+ // secrets filled. No node imports: the dashboard's settings form reads it
3
+ // in the browser. The trace itself is otel.ts.
4
+
5
+ export interface TelemetryConfig {
6
+ /** Where the trace is POSTed: the collector's OTLP/HTTP traces URL. */
7
+ endpoint: string;
8
+ /** Header name → value; a value written `${NAME}` is a vault secret's name. */
9
+ headers: Record<string, string>;
10
+ }
11
+
12
+ const SECRET_REF = /^\$\{([A-Z][A-Z0-9_]{1,63})\}$/;
13
+ const HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
14
+
15
+ /** The block as written, and what is wrong with it in words. */
16
+ export function parseTelemetry(raw: unknown): { config: TelemetryConfig | null; problems: string[] } {
17
+ if (raw === undefined || raw === null) return { config: null, problems: [] };
18
+ if (typeof raw !== "object" || Array.isArray(raw)) {
19
+ return { config: null, problems: ["telemetry: is a block — otlp: <https URL of the collector's /v1/traces>, and headers:"] };
20
+ }
21
+ const o = raw as Record<string, unknown>;
22
+ const problems: string[] = [];
23
+ for (const k of Object.keys(o)) if (k !== "otlp" && k !== "headers") problems.push(`telemetry: ${k} — not a key; the keys are otlp and headers`);
24
+ const endpoint = typeof o.otlp === "string" ? o.otlp.trim() : "";
25
+ let url: URL | null = null;
26
+ try {
27
+ url = endpoint ? new URL(endpoint) : null;
28
+ } catch {
29
+ url = null;
30
+ }
31
+ if (!url || url.protocol !== "https:") problems.push(`telemetry: otlp ${endpoint || "(missing)"} — an https:// URL, the collector's OTLP/HTTP traces endpoint (…/v1/traces)`);
32
+ const headers: Record<string, string> = {};
33
+ if (o.headers !== undefined) {
34
+ if (!o.headers || typeof o.headers !== "object" || Array.isArray(o.headers)) problems.push("telemetry: headers is a block of name: value");
35
+ else {
36
+ for (const [name, value] of Object.entries(o.headers as Record<string, unknown>)) {
37
+ if (!HEADER_NAME.test(name)) problems.push(`telemetry: header "${name}" — letters, digits and dashes`);
38
+ else if (typeof value !== "string" || !value.trim()) problems.push(`telemetry: header ${name} — a value, or \${SECRET_NAME} for one in the vault`);
39
+ else if (!SECRET_REF.test(value.trim()) && /token|key|secret|auth/i.test(name)) {
40
+ problems.push(`telemetry: header ${name} is written into the file — put it in the vault (foldrun secrets set NAME) and write \${NAME}`);
41
+ } else headers[name.toLowerCase()] = value.trim();
42
+ }
43
+ }
44
+ }
45
+ return { config: problems.length ? null : { endpoint, headers }, problems };
46
+ }
47
+
48
+ /** The vault secrets a config's headers name. */
49
+ export function telemetrySecretNames(cfg: TelemetryConfig): string[] {
50
+ return Object.values(cfg.headers).map((v) => SECRET_REF.exec(v)?.[1]).filter((n): n is string => !!n);
51
+ }
52
+
53
+ /** Header values with each `${NAME}` filled by `lookup`; the names it could not fill. */
54
+ export function telemetryHeaders(cfg: TelemetryConfig, lookup: (name: string) => string | null): { headers: Record<string, string>; missing: string[] } {
55
+ const headers: Record<string, string> = {};
56
+ const missing: string[] = [];
57
+ for (const [k, v] of Object.entries(cfg.headers)) {
58
+ const ref = SECRET_REF.exec(v)?.[1];
59
+ if (!ref) headers[k] = v;
60
+ else {
61
+ const value = lookup(ref);
62
+ if (value === null) missing.push(ref);
63
+ else headers[k] = value;
64
+ }
65
+ }
66
+ return { headers, missing };
67
+ }
package/src/otel.ts ADDED
@@ -0,0 +1,100 @@
1
+ // A run as an OpenTelemetry trace — OTLP/HTTP JSON, the body a collector's
2
+ // /v1/traces takes — so an account's agent runs sit in the tracing it
3
+ // already has (Grafana Tempo, Honeycomb, Datadog, Jaeger, Langfuse…) beside
4
+ // the services they act on. Built from buildTraceTree, the same tree the run
5
+ // page draws: run → step → model call → tool call.
6
+ //
7
+ // telemetry:
8
+ // otlp: https://otel.example.com/v1/traces
9
+ // headers:
10
+ // authorization: ${OTEL_TOKEN} # a vault secret, by name
11
+ //
12
+ // In AGENTS.md (the workspace's, else the account's — the block replaces
13
+ // whole, like notify:). Ids are derived from the run and span ids, so a run
14
+ // sent twice is the same trace, not two. No OpenTelemetry SDK: the wire
15
+ // format is small, and a dependency in every runner image is not.
16
+
17
+ import crypto from "node:crypto";
18
+ import type { RunRecord } from "./store.ts";
19
+ import { buildTraceTree, type TraceSpan } from "./trace-tree.ts";
20
+
21
+ export { parseTelemetry, telemetrySecretNames, telemetryHeaders, type TelemetryConfig } from "./otel-config.ts";
22
+
23
+ type AttrValue = { stringValue: string } | { intValue: string } | { doubleValue: number } | { boolValue: boolean };
24
+ type Attr = { key: string; value: AttrValue };
25
+
26
+ const attr = (key: string, v: string | number | boolean | null | undefined): Attr[] => {
27
+ if (v === null || v === undefined || v === "") return [];
28
+ if (typeof v === "boolean") return [{ key, value: { boolValue: v } }];
29
+ if (typeof v === "number") return [{ key, value: Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v } }];
30
+ return [{ key, value: { stringValue: v } }];
31
+ };
32
+
33
+ const hex = (s: string, len: number) => crypto.createHash("sha256").update(s).digest("hex").slice(0, len);
34
+ const nanos = (msEpoch: number) => (BigInt(Math.round(msEpoch)) * BigInt(1_000_000)).toString();
35
+
36
+ /** OTLP span kinds: INTERNAL, and CLIENT for a call that leaves the platform. */
37
+ const SPAN_KIND = { internal: 1, client: 3 } as const;
38
+
39
+ export interface TraceContext {
40
+ tenant: string;
41
+ workspace: string;
42
+ /** Agent name → its owner (owner: …), stamped on its step spans. */
43
+ owners?: Record<string, string | null>;
44
+ /** The platform version, as the instrumentation scope's. */
45
+ version?: string;
46
+ }
47
+
48
+ /** The ExportTraceServiceRequest for one run. */
49
+ export function runToOtlp(run: RunRecord, ctx: TraceContext, now = Date.now()) {
50
+ const tree = buildTraceTree(run, now);
51
+ const traceId = hex(`foldrun-trace\0${ctx.tenant}\0${ctx.workspace}\0${run.id}`, 32);
52
+ const spans: unknown[] = [];
53
+ const visit = (s: TraceSpan, parent: string | null, inherited: { start: number; agent?: string }) => {
54
+ const spanId = hex(`${traceId}\0${s.id}`, 16);
55
+ const start = s.start ?? inherited.start;
56
+ const end = Math.max(start, s.end ?? s.start ?? now);
57
+ const step = s.step !== undefined ? run.steps[s.step] : undefined;
58
+ const agent = s.kind === "step" ? step?.agent : inherited.agent;
59
+ spans.push({
60
+ traceId,
61
+ spanId,
62
+ ...(parent ? { parentSpanId: parent } : {}),
63
+ name: s.kind === "run" ? `run ${s.label}` : s.kind === "step" ? `step ${s.label}` : s.kind === "model" ? `chat ${s.model ?? s.label}` : s.kind === "tool" ? `tool ${s.label}` : s.label,
64
+ kind: s.kind === "model" ? SPAN_KIND.client : SPAN_KIND.internal,
65
+ startTimeUnixNano: nanos(start),
66
+ endTimeUnixNano: nanos(end),
67
+ attributes: [
68
+ ...attr("foldrun.span.kind", s.kind),
69
+ ...attr("foldrun.status", s.status),
70
+ ...(s.kind === "step" ? [...attr("foldrun.agent", agent), ...attr("foldrun.owner", agent ? ctx.owners?.[agent] ?? null : null), ...attr("foldrun.step", s.step)] : []),
71
+ ...attr("gen_ai.request.model", s.model),
72
+ ...(s.tokens ? [...attr("gen_ai.usage.input_tokens", s.tokens.input), ...attr("gen_ai.usage.output_tokens", s.tokens.output), ...attr("gen_ai.usage.cache_read_tokens", s.tokens.cacheRead), ...attr("gen_ai.usage.cache_write_tokens", s.tokens.cacheWrite)] : []),
73
+ ...attr("gen_ai.response.finish_reason", s.stop),
74
+ ...(typeof s.costUsd === "number" ? attr("foldrun.cost_usd", s.costUsd) : []),
75
+ ...(s.kind === "tool" ? [...attr("gen_ai.tool.name", s.label), ...attr("gen_ai.tool.call.id", s.call)] : []),
76
+ ...attr("foldrun.subagent", s.subagent),
77
+ ],
78
+ status: s.status === "error" ? { code: 2, ...(s.text ? { message: s.text.slice(0, 500) } : {}) } : { code: 0 },
79
+ });
80
+ for (const c of s.children) visit(c, spanId, { start, agent });
81
+ };
82
+ visit(tree, null, { start: Date.parse(run.startedAt) || now });
83
+ return {
84
+ resourceSpans: [
85
+ {
86
+ resource: {
87
+ attributes: [
88
+ ...attr("service.name", "foldrun"),
89
+ ...attr("foldrun.account", ctx.tenant),
90
+ ...attr("foldrun.workspace", ctx.workspace),
91
+ ...attr("foldrun.flow", run.flow),
92
+ ...attr("foldrun.run_id", run.id),
93
+ ...attr("foldrun.started_by", run.startedBy ?? null),
94
+ ],
95
+ },
96
+ scopeSpans: [{ scope: { name: "foldrun", ...(ctx.version ? { version: ctx.version } : {}) }, spans }],
97
+ },
98
+ ],
99
+ };
100
+ }
package/src/outcome.ts ADDED
@@ -0,0 +1,90 @@
1
+ // A run's outcome, and the one way every surface counts outcomes (FOL-54).
2
+ //
3
+ // A run is read on three separate lines, never folded into one "success":
4
+ // · execution — did it run to the end without failing? (completed, failed,
5
+ // stopped by a person, or still live)
6
+ // · result — what the work concluded: GOOD, QUIET, BLOCKED, BAD, or
7
+ // nothing said (done)
8
+ // · action — does a person need to do something? (an approval or a
9
+ // question waiting, a failure, a BLOCKED, a BAD finding)
10
+ //
11
+ // Before this, five surfaces computed five different "success rates" (one
12
+ // counted BAD as success, one counted stopped runs as failures, …), so the
13
+ // same desk read four different percentages. Every page, the API and the
14
+ // CLI now read `tally()`. Pure: no node imports, so the browser can use it.
15
+
16
+ export type Outcome = "running" | "queued" | "waiting" | "good" | "done" | "quiet" | "blocked" | "bad" | "failed" | "stopped";
17
+
18
+ export const OUTCOMES: readonly Outcome[] = ["running", "queued", "waiting", "good", "done", "quiet", "blocked", "bad", "failed", "stopped"];
19
+
20
+ /** From the display status (store.runDisplayStatus) and the verdict (store.runVerdict). */
21
+ export function outcomeOf(status: string, verdict: string | null | undefined): Outcome {
22
+ if (status === "running") return "running";
23
+ if (status === "queued") return "queued";
24
+ if (status === "awaiting-approval") return "waiting";
25
+ if (status === "stopped") return "stopped";
26
+ if (status === "failed") return "failed";
27
+ switch (verdict) {
28
+ case "GOOD": return "good";
29
+ case "QUIET": return "quiet";
30
+ case "BLOCKED": return "blocked";
31
+ case "BAD": return "bad";
32
+ default: return "done";
33
+ }
34
+ }
35
+
36
+ /** Still going: not an outcome yet. */
37
+ export const isLive = (o: Outcome) => o === "running" || o === "queued" || o === "waiting";
38
+ /** Ran to the end without failing — whatever it found. */
39
+ export const isCompleted = (o: Outcome) => o === "good" || o === "done" || o === "quiet" || o === "blocked" || o === "bad";
40
+ /** Over and countable for a rate: completed or failed. A run a person stopped is left out. */
41
+ export const isFinished = (o: Outcome) => isCompleted(o) || o === "failed";
42
+ /** Went wrong or found something wrong: failed, BAD, BLOCKED. */
43
+ export const needsAttention = (o: Outcome) => o === "failed" || o === "bad" || o === "blocked";
44
+ /** A person has something to do: what needsAttention says, and a run
45
+ * waiting on an approval or an answer (Codex review). */
46
+ export const needsAction = (o: Outcome) => needsAttention(o) || o === "waiting";
47
+ /** Completed and reported nothing wrong (GOOD, QUIET, or nothing said). */
48
+ export const isSuccess = (o: Outcome) => o === "good" || o === "done" || o === "quiet";
49
+
50
+ export interface Tally {
51
+ /** Every run counted, live ones included. */
52
+ runs: number;
53
+ /** Running, queued, or waiting on a person. */
54
+ live: number;
55
+ /** Waiting on an approval or an answer. */
56
+ waiting: number;
57
+ stopped: number;
58
+ /** Completed + failed: the runs a rate is taken over. */
59
+ finished: number;
60
+ /** Ran to the end without failing. */
61
+ completed: number;
62
+ failed: number;
63
+ /** What the completed runs found. */
64
+ ok: number;
65
+ quiet: number;
66
+ blocked: number;
67
+ bad: number;
68
+ /** completed / finished, as a whole percent; null with nothing finished. */
69
+ completedPct: number | null;
70
+ }
71
+
72
+ /** The one count of a set of outcomes that every surface shows. */
73
+ export function tally(outcomes: Iterable<Outcome>): Tally {
74
+ const t: Tally = { runs: 0, live: 0, waiting: 0, stopped: 0, finished: 0, completed: 0, failed: 0, ok: 0, quiet: 0, blocked: 0, bad: 0, completedPct: null };
75
+ for (const o of outcomes) {
76
+ t.runs++;
77
+ if (isLive(o)) t.live++;
78
+ if (o === "waiting") t.waiting++;
79
+ if (o === "stopped") t.stopped++;
80
+ if (isFinished(o)) t.finished++;
81
+ if (isCompleted(o)) t.completed++;
82
+ if (o === "failed") t.failed++;
83
+ if (o === "good" || o === "done") t.ok++;
84
+ if (o === "quiet") t.quiet++;
85
+ if (o === "blocked") t.blocked++;
86
+ if (o === "bad") t.bad++;
87
+ }
88
+ t.completedPct = t.finished ? Math.round((100 * t.completed) / t.finished) : null;
89
+ return t;
90
+ }
package/src/package.ts CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  import fs from "node:fs";
28
28
  import path from "node:path";
29
- import matter from "gray-matter";
29
+ import matter from "./matter.ts";
30
30
  import {
31
31
  assertSafeName,
32
32
  isEditablePath,
package/src/platform.ts CHANGED
@@ -29,7 +29,7 @@ export type Edition = "self-hosted" | "hosted";
29
29
  * told, because a silent drop reads exactly like a broken hook. */
30
30
  export type TriggerAdmission =
31
31
  | { admit: true }
32
- | { admit: false; reason: "duplicate" | "throttled" | "quarantined" | "paused" | "debounced"; detail: string; firesAt?: number };
32
+ | { admit: false; reason: "duplicate" | "throttled" | "quarantined" | "paused" | "halted" | "debounced"; detail: string; firesAt?: number };
33
33
 
34
34
  export interface PlatformHooks {
35
35
  /** Is this a hosted install? The ONE question every commercial surface —
@@ -0,0 +1,90 @@
1
+ // The system prompt in layers, ordered for the model's prompt cache.
2
+ //
3
+ // Every provider caches a prompt by its prefix: a request whose opening
4
+ // bytes match one it has seen reads the model's work on them instead of
5
+ // paying for it again, and a byte that differs invalidates everything
6
+ // after it. agentContext used to join its sections in the order it
7
+ // computed them, which put the memory index, the state file list and the
8
+ // clock — the three things that change between runs — in the middle.
9
+ //
10
+ // This collector keeps the sections apart by how often they change and
11
+ // assembles them stable first, dynamic last: what Anthropic does for
12
+ // Claude Code ("static content first, dynamic content last"). The prompt
13
+ // still goes to the SDK as one string, cached as one block. The Agent
14
+ // SDK's SYSTEM_PROMPT_DYNAMIC_BOUNDARY would split it, but the CLI honours
15
+ // the marker only when it talks to api.anthropic.com directly — a step
16
+ // behind the egress proxy got one block anyway (live check, 20 Oct 2026;
17
+ // claude-code issue #102518) — and where it does split, it sends the
18
+ // stable half with the CLI's "global" cache scope, which is not a scope to
19
+ // put an account's own prose in. So no marker.
20
+ //
21
+ // Each layer is also hashed, and the hashes ride on the step record
22
+ // (`promptLayers`). A step whose cache writes are high can then say which
23
+ // layer changed since the agent's last step — "memory changed" — instead
24
+ // of leaving a person to diff two 40k-character prompts.
25
+
26
+ import crypto from "node:crypto";
27
+ import type { PromptLayerRecord } from "./tokens.ts";
28
+
29
+ // The record's shape and the readers' comparison live in tokens.ts, which
30
+ // has no node imports and is what the dashboard's client bundle reads.
31
+ export { promptLayersChanged, promptCacheNote, type PromptLayerRecord } from "./tokens.ts";
32
+
33
+ export interface AssembledPrompt {
34
+ /** The whole prompt, stable layers first. */
35
+ text: string;
36
+ layers: PromptLayerRecord[];
37
+ }
38
+
39
+ const SEP = "\n\n";
40
+
41
+ export function hashLayer(text: string): string {
42
+ return crypto.createHash("sha256").update(text).digest("hex").slice(0, 12);
43
+ }
44
+
45
+ /**
46
+ * A collector agentContext fills in whatever order it computes its
47
+ * sections. `add` with the same name twice appends to that layer, so a
48
+ * section built in two places (the platform's rules) is one layer.
49
+ */
50
+ export class PromptLayers {
51
+ private readonly order: string[] = [];
52
+ private readonly text = new Map<string, string[]>();
53
+ private readonly dynamic = new Set<string>();
54
+
55
+ add(name: string, text: string, opts: { dynamic?: boolean } = {}): void {
56
+ if (!text) return;
57
+ if (!this.text.has(name)) {
58
+ this.order.push(name);
59
+ this.text.set(name, []);
60
+ }
61
+ this.text.get(name)!.push(text);
62
+ if (opts.dynamic) this.dynamic.add(name);
63
+ }
64
+
65
+ assemble(): AssembledPrompt {
66
+ const parts: string[] = [];
67
+ const layers: PromptLayerRecord[] = [];
68
+ const take = (names: string[], isDynamic: boolean) => {
69
+ for (const name of names) {
70
+ const joined = this.text.get(name)!.join(SEP);
71
+ parts.push(joined);
72
+ layers.push({ name, chars: joined.length, hash: hashLayer(joined), ...(isDynamic ? { stable: false } : {}) });
73
+ }
74
+ };
75
+ take(this.order.filter((n) => !this.dynamic.has(n)), false);
76
+ take(this.order.filter((n) => this.dynamic.has(n)), true);
77
+ return { text: parts.join(SEP), layers };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The key an OpenAI-format provider routes a prompt by (`prompt_cache_key`).
83
+ * One per agent: every step of an agent opens with the same prefix, and a
84
+ * key that names the account keeps one tenant's cache from being probed
85
+ * through another's requests. Hashed, since the names are a person's and
86
+ * the parameter is capped at 64 characters.
87
+ */
88
+ export function promptCacheKey(tenant: string, workspace: string, agent: string): string {
89
+ return `fr-${crypto.createHash("sha256").update(`${tenant}\u0000${workspace}\u0000${agent}`).digest("hex").slice(0, 32)}`;
90
+ }
@@ -21,7 +21,7 @@
21
21
  import { accountModelBlock } from "./model-credential.ts";
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
- import matter from "gray-matter";
24
+ import matter from "./matter.ts";
25
25
  import { getSecret } from "./secrets.ts";
26
26
  import { noteSecretUse, healthKey } from "./secret-health.ts";
27
27
  import { accountDir, workspaceDir, listWorkspaces, listAgents, parseProvider, type ProviderSpec } from "./store.ts";