@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
package/src/store.ts CHANGED
@@ -31,7 +31,8 @@ import { platform } from "./platform.ts";
31
31
  import { toLF, asRaw } from "./eol.ts";
32
32
  import type { TestEffect } from "./test-mode.ts";
33
33
  import type { OperatorEvent } from "./operator.ts";
34
- import matter from "gray-matter";
34
+ import matter from "./matter.ts";
35
+ import { readAgentsMdKeys } from "./agents-md.ts";
35
36
 
36
37
  /**
37
38
  * The frontmatter of one markdown file, as the runtime reads it. Exported
@@ -952,6 +953,12 @@ export interface ApiSpec {
952
953
  export interface AgentInfo {
953
954
  name: string;
954
955
  description: string;
956
+ /** `owner:` — the person accountable for this agent: its own, else the
957
+ * workspace AGENTS.md's, else the account's. Null when none of the three
958
+ * names one. */
959
+ owner: string | null;
960
+ /** Which of the three `owner` came from. */
961
+ ownerFrom: OwnerSource | null;
955
962
  model: string;
956
963
  /** The other half of model selection. listAgents has always returned it;
957
964
  * the interface did not say so, so every consumer had to re-read the file
@@ -1020,6 +1027,10 @@ export interface AgentInfo {
1020
1027
  /** Tools this agent declares inline rather than in `tools:` — `apis:`,
1021
1028
  * `scripts:` and `mcpServers:` names — which a limit may also name. */
1022
1029
  inlineTools: string[];
1030
+ /** agent.md's frontmatter does not parse (bad YAML): the agent is listed
1031
+ * with defaults and this says why, rather than the listing throwing —
1032
+ * one broken file once took down every account's worker (20 Oct 2026). */
1033
+ frontmatterError?: string;
1023
1034
  }
1024
1035
 
1025
1036
  // Recognised HTTP verbs a tool may declare. Deliberately NOT named
@@ -1355,6 +1366,9 @@ export interface FlowInfo {
1355
1366
  * overlap) — a default that changed underfoot would surprise every flow
1356
1367
  * that legitimately runs in parallel. */
1357
1368
  overlap: "skip" | "queue" | null;
1369
+ /** `issues: auto-close`: a run that goes fine closes this flow's open
1370
+ * issues (FOL-72 step 7). Unset is `stay-open`: a person resolves them. */
1371
+ issues?: "auto-close" | "stay-open";
1358
1372
  /** Where this flow's runs stand in the queue against everyone else's:
1359
1373
  * "high" is claimed before the round-robin, "low" after it. Adds to the
1360
1374
  * account's own priority; the account's cap still bounds how many run
@@ -1467,6 +1481,13 @@ export interface FlowInfo {
1467
1481
  * something rather than sitting in every file unread. */
1468
1482
  description: string | null;
1469
1483
 
1484
+ /** `owner:` — the person accountable for this flow: its own, else the
1485
+ * workspace AGENTS.md's, else the account's. parseFlow sees only the
1486
+ * file, so it fills the flow's own; listFlows and readFlow add the rest. */
1487
+ owner: string | null;
1488
+ /** Which of the three `owner` came from. */
1489
+ ownerFrom: OwnerSource | null;
1490
+
1470
1491
  /** Flow-level frontmatter values the parser could not read — a bad
1471
1492
  * `overlap:`/`priority:`/`catchup:`/`on:`/`signature:`, or a step line
1472
1493
  * whose `[[target]]` is malformed. Errors, the way a bad step option is,
@@ -1574,7 +1595,17 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1574
1595
  .filter((n) => fs.existsSync(path.join(dir, n, "agent.md")))
1575
1596
  .sort()
1576
1597
  .map((name) => {
1577
- const { data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8"));
1598
+ // One agent whose YAML does not parse is that agent's problem, not
1599
+ // the account's: listed with defaults and the reason, never thrown
1600
+ // out of a listing every page, the scheduler and the worker's boot
1601
+ // read.
1602
+ let data: Record<string, any> = {};
1603
+ let frontmatterError: string | undefined;
1604
+ try {
1605
+ ({ data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8")));
1606
+ } catch (err) {
1607
+ frontmatterError = frontmatterErrorText(err);
1608
+ }
1578
1609
  const tools = refNames(data.tools);
1579
1610
  const actions = webConfig(data, { ...accountFront, ...workspaceFront }).actions;
1580
1611
  const readsWeb = tools.includes("web") && (!actions || actions.some((a) => a === "fetch" || a === "browse"));
@@ -1592,6 +1623,7 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1592
1623
  network,
1593
1624
  name,
1594
1625
  description: data.description ?? "",
1626
+ ...resolveOwner(data.owner, workspaceFront.owner, accountFront.owner),
1595
1627
  model: data.model ?? "default",
1596
1628
  effort: data.effort ?? null,
1597
1629
  tools,
@@ -1615,10 +1647,25 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1615
1647
  ...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
1616
1648
  networkProblems: networkProblems(data.network),
1617
1649
  inlineTools: inlineToolNames(data),
1650
+ ...(frontmatterError ? { frontmatterError } : {}),
1618
1651
  };
1619
1652
  });
1620
1653
  }
1621
1654
 
1655
+ /** The first line of a YAML parse error, with where it is: what a person
1656
+ * needs to fix the file, without the parser's excerpt. */
1657
+ export function frontmatterErrorText(err: unknown): string {
1658
+ const msg = err instanceof Error ? err.message : String(err);
1659
+ return `frontmatter does not parse — ${msg.split("\n")[0].trim()}. Quote a value that contains a colon followed by a space.`;
1660
+ }
1661
+
1662
+ /** The body of a file whose frontmatter would not parse: what follows the
1663
+ * closing fence, so a flow's steps can still be read and shown. */
1664
+ function bodyAfterFrontmatter(raw: string): string {
1665
+ const m = raw.match(/^---\n[\s\S]*?\n---[ \t]*(?:\n|$)/);
1666
+ return m ? raw.slice(m[0].length) : raw;
1667
+ }
1668
+
1622
1669
  // A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
1623
1670
  // which is how one flow triggers another.
1624
1671
  // `(\S.*|)` rather than `(.*)`: the instruction either starts with a
@@ -1784,11 +1831,24 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1784
1831
  // every line kept its "\r", which STEP_RE's `.` will not match. Read it as
1785
1832
  // LF; the line count, and so every step's `line`, is the same.
1786
1833
  raw = raw.replace(/\r\n/g, "\n");
1787
- const { data, content } = matter(raw);
1834
+ // Frontmatter that does not parse is a flow-level problem (an error in
1835
+ // check), not a throw: listFlows feeds every workspace listing, the
1836
+ // scheduler and the worker's boot. With no frontmatter read the flow is
1837
+ // manual, so a broken schedule never fires.
1838
+ let data: Record<string, any> = {};
1839
+ let content: string;
1840
+ let frontError: string | null = null;
1841
+ try {
1842
+ ({ data, content } = matter(raw));
1843
+ } catch (err) {
1844
+ frontError = frontmatterErrorText(err);
1845
+ content = bodyAfterFrontmatter(raw);
1846
+ }
1788
1847
  const steps: FlowStep[] = [];
1789
1848
  // Flow-level problems: a malformed step target (below) and unreadable
1790
1849
  // frontmatter enums (before the return). Reported as errors by flow-lint.
1791
1850
  const frontProblems: string[] = [];
1851
+ if (frontError) frontProblems.push(frontError);
1792
1852
  // Frontmatter is stripped by matter(), so add it back to keep line numbers
1793
1853
  // pointing at the real file rather than the body.
1794
1854
  const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
@@ -1990,6 +2050,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1990
2050
  }
1991
2051
  };
1992
2052
  badEnum("overlap", data.overlap, ["skip", "queue"]);
2053
+ badEnum("issues", data.issues, ["stay-open", "auto-close"]);
1993
2054
  badEnum("priority", data.priority, ["high", "normal", "low"]);
1994
2055
  badEnum("catchup", data.catchup, ["none", "last"]);
1995
2056
  badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
@@ -2005,6 +2066,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
2005
2066
  model: data.model ?? null,
2006
2067
  effort: data.effort ?? null,
2007
2068
  overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
2069
+ ...(data.issues === "auto-close" || data.issues === "stay-open" ? { issues: data.issues } : {}),
2008
2070
  live: data.live === true,
2009
2071
  priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
2010
2072
  budget: runBudget(data.budget),
@@ -2041,6 +2103,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
2041
2103
  approvers: approverList(data.approvers),
2042
2104
  approveWithin: durationOf(data.approve_within),
2043
2105
  description: typeof data.description === "string" ? data.description.trim() || null : null,
2106
+ ...resolveOwner(data.owner, undefined, undefined, "flow"),
2044
2107
  frontProblems,
2045
2108
  steps,
2046
2109
  };
@@ -2380,11 +2443,45 @@ function groupBlocks(blocks: FlowBlock[]): FlowBlock[][] {
2380
2443
  export function listFlows(tenant: string, workspace: string): FlowInfo[] {
2381
2444
  const dir = path.join(workspaceDir(tenant, workspace), "flows");
2382
2445
  if (!fs.existsSync(dir)) return [];
2446
+ const owned = flowOwner(tenant, workspace);
2383
2447
  return fs
2384
2448
  .readdirSync(dir)
2385
2449
  .filter((f) => f.endsWith(".md"))
2386
2450
  .sort()
2387
- .map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
2451
+ .map((f) => owned(parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"))));
2452
+ }
2453
+
2454
+ /** A flow with its owner filled from the workspace and account when its own
2455
+ * file names none. */
2456
+ function flowOwner(tenant: string, workspace: string): (flow: FlowInfo) => FlowInfo {
2457
+ let above: { workspace: unknown; account: unknown } | null = null;
2458
+ return (flow) => {
2459
+ if (flow.owner) return flow;
2460
+ above ??= {
2461
+ workspace: readAgentsMdKeys(workspaceDir(tenant, workspace)).owner,
2462
+ account: readAgentsMdKeys(accountDir(tenant)).owner,
2463
+ };
2464
+ return { ...flow, ...resolveOwner(undefined, above.workspace, above.account) };
2465
+ };
2466
+ }
2467
+
2468
+ export type OwnerSource = "agent" | "flow" | "workspace" | "account";
2469
+
2470
+ /** `owner:` down the cascade: the file's own, the workspace AGENTS.md's, the
2471
+ * account's. A blank or non-string value is no value. `own` is labelled
2472
+ * "agent" or "flow" by the caller's kind; here it is whichever is given. */
2473
+ export function resolveOwner(
2474
+ own: unknown,
2475
+ workspace?: unknown,
2476
+ account?: unknown,
2477
+ ownKind: "agent" | "flow" = "agent",
2478
+ ): { owner: string | null; ownerFrom: OwnerSource | null } {
2479
+ const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : null);
2480
+ for (const [v, from] of [[own, ownKind], [workspace, "workspace"], [account, "account"]] as const) {
2481
+ const t = text(v);
2482
+ if (t) return { owner: t, ownerFrom: from };
2483
+ }
2484
+ return { owner: null, ownerFrom: null };
2388
2485
  }
2389
2486
 
2390
2487
  /** One flow of a workspace by name, or null. The same read listFlows makes,
@@ -2395,7 +2492,7 @@ export function readFlow(tenant: string, workspace: string, flowName: string): F
2395
2492
  if (!fs.existsSync(dir)) return null;
2396
2493
  for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
2397
2494
  const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
2398
- if (flow.name === flowName) return flow;
2495
+ if (flow.name === flowName) return flowOwner(tenant, workspace)(flow);
2399
2496
  }
2400
2497
  return null;
2401
2498
  }
@@ -3068,6 +3165,60 @@ export interface RunEvent {
3068
3165
  /** Set on the line a hook wrote — a deny, a rewrite, context, a failure
3069
3166
  * (hooks.ts). The run page and the Hooks page read these. */
3070
3167
  hook?: { hook: string; event: string; did: string; tool?: string; reason?: string; ms: number };
3168
+ /** Set on a tool event whose content was recorded (FOL-54 step 4): the
3169
+ * call's input, on the call; its output, on the completion. The content is
3170
+ * under runs/<run>/calls/<call>.json, secrets masked — readCapturedCall.
3171
+ * Absent when the workspace says `capture: metadata`, and before 19 Oct
3172
+ * 2026. */
3173
+ captured?: boolean;
3174
+ /** On a tool call: the id of the model call that asked for it (a
3175
+ * ModelCall's `id`), so the trace can nest it. Since 19 Oct 2026. */
3176
+ inCall?: string;
3177
+ }
3178
+
3179
+ /** One model call in a step (FOL-54 step 4): what the model was sent back
3180
+ * for and what came of it. A step's turns, one by one, where the step
3181
+ * record had only their sum. */
3182
+ export interface ModelCall {
3183
+ /** The provider's message id. */
3184
+ id: string;
3185
+ /** 1, 2, 3 … in the order they came back. */
3186
+ n: number;
3187
+ /** When the response arrived. */
3188
+ at: string;
3189
+ /** From the request going out (the step's start, or the last tool result
3190
+ * coming back) to the response arriving. */
3191
+ ms: number;
3192
+ model?: string;
3193
+ /** Why the model stopped: end_turn, tool_use, max_tokens, … */
3194
+ stop?: string;
3195
+ /** All input (fresh + cache), as the step's own `tokens` counts it. */
3196
+ tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
3197
+ /** How many tools it called in this turn. */
3198
+ tools: number;
3199
+ /** Set when a sub-agent (`subagents:`) made the call. */
3200
+ subagent?: string;
3201
+ /** Which attempt of the step made it, from the second on. */
3202
+ attempt?: number;
3203
+ }
3204
+
3205
+ /** The most of one tool input or output a run records (FOL-54 step 4). */
3206
+ export const CAPTURE_CAP = 64 * 1024;
3207
+
3208
+ /** What a recorded tool call took and gave, secrets masked, each part capped. */
3209
+ export interface CapturedCall {
3210
+ id: string;
3211
+ tool: string;
3212
+ input?: string;
3213
+ output?: string;
3214
+ /** The whole input's / output's length in characters, before masking and
3215
+ * the cap; `truncated` says whether the text was cut. */
3216
+ inputChars?: number;
3217
+ outputChars?: number;
3218
+ /** Either part cut; and which. */
3219
+ truncated?: boolean;
3220
+ inputCut?: boolean;
3221
+ outputCut?: boolean;
3071
3222
  }
3072
3223
 
3073
3224
  /** A question an agent asked a person while its step ran (tools: [ask]). */
@@ -3083,12 +3234,18 @@ export interface StepQuestion {
3083
3234
  unanswered?: boolean;
3084
3235
  }
3085
3236
 
3086
- /** A message a person sent into a running step, and when the agent got it. */
3237
+ /** A message a person sent into a running step, and when the agent got it —
3238
+ * or, with `from`, one a colleague in the same parallel group sent it
3239
+ * (tools: [peers]); with `to`, one this step sent its colleagues. */
3087
3240
  export interface StepMessage {
3088
3241
  text: string;
3089
3242
  by?: string | null;
3090
3243
  at: string;
3091
3244
  deliveredAt: string;
3245
+ /** The sibling agent that sent it (`by` is `peer:<agent>` then). */
3246
+ from?: string;
3247
+ /** The sibling agents this step sent it to. */
3248
+ to?: string[];
3092
3249
  }
3093
3250
 
3094
3251
  /** One attempt of a step — see StepRecord.tries. */
@@ -3137,8 +3294,8 @@ export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" |
3137
3294
  };
3138
3295
  }
3139
3296
 
3140
- export { type TokenUsage, tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens } from "./tokens.ts";
3141
- import { sumTokens, type TokenUsage } from "./tokens.ts";
3297
+ export { type TokenUsage, type PromptLayerRecord, tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens, promptLayersChanged, promptCacheNote } from "./tokens.ts";
3298
+ import { sumTokens, type TokenUsage, type PromptLayerRecord } from "./tokens.ts";
3142
3299
 
3143
3300
  export interface StepRecord {
3144
3301
  agent: string;
@@ -3211,6 +3368,9 @@ export interface StepRecord {
3211
3368
  * records from before this existed; readers fall back to the events. */
3212
3369
  startedAt?: string;
3213
3370
  finishedAt?: string;
3371
+ /** Every model call the step made, in order (FOL-54 step 4). Absent before
3372
+ * 19 Oct 2026. */
3373
+ calls?: ModelCall[];
3214
3374
  /** Why a step was skipped, shown in the trace. */
3215
3375
  skipReason?: string;
3216
3376
  /** Evaluator loop, carried from the flow — see FlowStep. `loopRemaining`
@@ -3324,6 +3484,11 @@ export interface StepRecord {
3324
3484
  * what says where a step's input tokens went. Absent before 18 Oct 2026. */
3325
3485
  turns?: number | null;
3326
3486
  systemPromptChars?: number;
3487
+ /** The system prompt's layers — name, length and hash of each, stable
3488
+ * ones first, the ones that change between runs marked. Two steps of
3489
+ * one agent compared layer by layer say why the model's cache missed
3490
+ * (prompt-layers.ts). Absent before 20 Oct 2026. */
3491
+ promptLayers?: PromptLayerRecord[];
3327
3492
  /** Why the model's last turn ended (end_turn, max_tokens, tool_use…), as
3328
3493
  * the SDK's closing result says. */
3329
3494
  stopReason?: string | null;
@@ -3392,6 +3557,17 @@ export interface RunRecord {
3392
3557
  * the agents decided was worth remembering, listed so a person can review
3393
3558
  * the lesson without diffing the folder. */
3394
3559
  memoryWrites?: string[];
3560
+ /** The workspace's deployed commit when the run was created — which
3561
+ * version of the files it ran (FOL-54 step 4). Absent for a workspace
3562
+ * never deployed, and before 19 Oct 2026. */
3563
+ revision?: string | null;
3564
+ /** When a worker first picked the run up: startedAt is when it was
3565
+ * created, so the gap is its time in the queue. */
3566
+ drivenAt?: string | null;
3567
+ /** A re-run's source (FOL-72 step 6): which run, the 1-based step it
3568
+ * started from, and the instruction it was given there when a person
3569
+ * edited it for this re-run alone. */
3570
+ rerunOf?: { run: string; step: number; instruction?: string };
3395
3571
  /** Who asked for this run, when a person did: their email address. A
3396
3572
  * schedule, a webhook and a chained flow have no one, and leave it unset.
3397
3573
  * Kept so a gate can refuse the one approval nobody should be able to
@@ -3415,6 +3591,50 @@ export interface RunRecord {
3415
3591
  steps: StepRecord[];
3416
3592
  }
3417
3593
 
3594
+ // ---------------------------------------------------------------- captured calls
3595
+ //
3596
+ // What each tool call took and gave (FOL-54 step 4), beside the run under
3597
+ // runs/<run>/calls/<call>.json — out of the run record, which every list
3598
+ // and every page reads whole, and gone with the run (deleteRun removes
3599
+ // runs/<run>/). The runner writes them, secrets already masked.
3600
+
3601
+ const SAFE_ID = /^[A-Za-z0-9_.-]{1,128}$/;
3602
+
3603
+ function capturedCallFile(workspaceRoot: string, runId: string, callId: string): string | null {
3604
+ if (!SAFE_ID.test(runId) || !SAFE_ID.test(callId) || callId.startsWith(".") || runId.startsWith(".")) return null;
3605
+ return path.join(workspaceRoot, "runs", runId, "calls", `${callId}.json`);
3606
+ }
3607
+
3608
+ /** Merge one part (the input, on the call; the output, on its completion)
3609
+ * into the call's file. */
3610
+ export function writeCapturedCall(workspaceRoot: string, runId: string, part: CapturedCall): void {
3611
+ const file = capturedCallFile(workspaceRoot, runId, part.id);
3612
+ if (!file) return;
3613
+ let prev: Partial<CapturedCall> = {};
3614
+ try {
3615
+ prev = JSON.parse(fs.readFileSync(file, "utf8")) as CapturedCall;
3616
+ } catch {
3617
+ /* the first part */
3618
+ }
3619
+ const next: CapturedCall = { ...prev, ...part, truncated: Boolean(prev.truncated || part.truncated) || undefined };
3620
+ if (!next.truncated) delete next.truncated;
3621
+ fs.mkdirSync(path.dirname(file), { recursive: true });
3622
+ const tmp = `${file}.${process.pid}.tmp`;
3623
+ fs.writeFileSync(tmp, JSON.stringify(next));
3624
+ fs.renameSync(tmp, file);
3625
+ }
3626
+
3627
+ /** One recorded call of a run, or null (not recorded, or not a call). */
3628
+ export function readCapturedCall(tenant: string, workspace: string, runId: string, callId: string): CapturedCall | null {
3629
+ const file = capturedCallFile(workspaceDir(tenant, workspace), runId, callId);
3630
+ if (!file) return null;
3631
+ try {
3632
+ return JSON.parse(fs.readFileSync(file, "utf8")) as CapturedCall;
3633
+ } catch {
3634
+ return null;
3635
+ }
3636
+ }
3637
+
3418
3638
  export function runFilePath(tenant: string, workspace: string, runId: string) {
3419
3639
  assertSafeName(runId, "run id");
3420
3640
  return path.join(workspaceDir(tenant, workspace), "runs", `${runId}.json`);
@@ -3619,6 +3839,11 @@ export interface RunSummaryStep {
3619
3839
  instruction?: string;
3620
3840
  /** When a parked step last spoke — the moment the banner sorts by. */
3621
3841
  waitingSince?: string;
3842
+ /** For search (FOL-72 step 9): the tools it called, how many of its calls
3843
+ * errored, and its model. Absent on rows written before 19 Oct 2026. */
3844
+ tools?: string[];
3845
+ toolErrors?: number;
3846
+ model?: string;
3622
3847
  startedAt?: string;
3623
3848
  finishedAt?: string;
3624
3849
  }
@@ -3637,6 +3862,8 @@ export interface RunSummary {
3637
3862
  tags?: string[];
3638
3863
  startedBy?: string | null;
3639
3864
  parkedAt?: string | null;
3865
+ /** The revision it ran (RunRecord.revision), for issues' "since". */
3866
+ revision?: string | null;
3640
3867
  /** A step is parked for a person — what the approvals banner counts. */
3641
3868
  awaitingApproval: boolean;
3642
3869
  steps: RunSummaryStep[];
@@ -3680,6 +3907,7 @@ export function summarizeRun(run: RunRecord): RunSummary {
3680
3907
  ...(run.tags?.length ? { tags: run.tags } : {}),
3681
3908
  ...(run.startedBy ? { startedBy: run.startedBy } : {}),
3682
3909
  ...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
3910
+ ...(run.revision ? { revision: run.revision } : {}),
3683
3911
  awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
3684
3912
  steps: run.steps.map((s) => {
3685
3913
  const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
@@ -3698,11 +3926,29 @@ export function summarizeRun(run: RunRecord): RunSummary {
3698
3926
  ...(s.status === "awaiting-approval" ? { instruction: s.instruction, waitingSince: s.events.at(-1)?.t ?? run.startedAt } : {}),
3699
3927
  ...(s.startedAt ? { startedAt: s.startedAt } : {}),
3700
3928
  ...(s.finishedAt ? { finishedAt: s.finishedAt } : {}),
3929
+ ...searchFacts(s),
3701
3930
  };
3702
3931
  }),
3703
3932
  };
3704
3933
  }
3705
3934
 
3935
+ /** What a step's row carries for search: the tools it called (once each),
3936
+ * how many tool calls errored, and its model. */
3937
+ function searchFacts(s: StepRecord): { tools?: string[]; toolErrors?: number; model?: string } {
3938
+ const tools = new Set<string>();
3939
+ let errors = 0;
3940
+ for (const e of s.events) {
3941
+ if (e.type !== "tool") continue;
3942
+ if (e.ms == null) tools.add(e.text);
3943
+ else if (e.err) errors++;
3944
+ }
3945
+ return {
3946
+ ...(tools.size ? { tools: [...tools].slice(0, 40) } : {}),
3947
+ ...(errors ? { toolErrors: errors } : {}),
3948
+ ...(s.model ? { model: s.model } : {}),
3949
+ };
3950
+ }
3951
+
3706
3952
  /** A RunRecord with the shape a list reads and nothing else — see listAllRuns. */
3707
3953
  export function runFromSummary(s: RunSummary): RunRecord {
3708
3954
  const { awaitingApproval: _awaiting, ...run } = s;
package/src/tokens.ts CHANGED
@@ -73,3 +73,71 @@ export function runTokens(run: { steps: readonly { tokens?: TokenUsage | null }[
73
73
  const toks = run.steps.map((s) => s.tokens).filter((t): t is TokenUsage => !!t);
74
74
  return toks.length ? sumTokens(toks) : null;
75
75
  }
76
+
77
+ /**
78
+ * One layer of a step's system prompt as the record keeps it — no text,
79
+ * only what tells two steps' prompts apart. The layers are assembled in
80
+ * prompt-layers.ts; this is the shape the run page and the CLI read.
81
+ */
82
+ export interface PromptLayerRecord {
83
+ name: string;
84
+ chars: number;
85
+ /** First 12 hex characters of the layer's SHA-256. */
86
+ hash: string;
87
+ /** False for a layer that changes between runs (memory, state, clock),
88
+ * placed after the cache boundary. Omitted when true. */
89
+ stable?: false;
90
+ }
91
+
92
+ /**
93
+ * Which layers differ between two steps' prompts, by name: changed, added
94
+ * or removed. Empty when the prompts are the same, or either step has no
95
+ * layers on record.
96
+ */
97
+ export function promptLayersChanged(prev: readonly PromptLayerRecord[] | null | undefined, next: readonly PromptLayerRecord[] | null | undefined): string[] {
98
+ if (!prev || !next) return [];
99
+ const before = new Map(prev.map((l) => [l.name, l.hash]));
100
+ const after = new Map(next.map((l) => [l.name, l.hash]));
101
+ const out: string[] = [];
102
+ for (const [name, hash] of after) if (before.get(name) !== hash) out.push(name);
103
+ for (const name of before.keys()) if (!after.has(name)) out.push(name);
104
+ return out;
105
+ }
106
+
107
+ /** A step that wrote this share of its input to the cache, or more, gets a
108
+ * note saying why. Below it the cache did its job: a step's turns each
109
+ * write their new tail, which is a few percent of a long step's input. */
110
+ export const CACHE_WRITE_NOTE_SHARE = 0.2;
111
+
112
+ /**
113
+ * Why a step's prompt cache missed, in one line, or null when it did not:
114
+ * the share of input written to the cache, and what differed from the same
115
+ * agent's previous step in the run — "memory, clock changed since step 2".
116
+ * The agent's first step in a run writes its prefix whatever happens, and
117
+ * says so; the same prompt written again means the entry had expired
118
+ * (five minutes on an API key) or siblings started before it was readable.
119
+ * Steps without layers on record (before 20 Oct 2026) get no note.
120
+ */
121
+ export function promptCacheNote(
122
+ steps: readonly { agent: string; tokens?: TokenUsage | null; promptLayers?: readonly PromptLayerRecord[] | null }[],
123
+ index: number,
124
+ ): string | null {
125
+ const step = steps[index];
126
+ if (!step?.promptLayers) return null;
127
+ const t = step.tokens;
128
+ if (!t || t.cacheWrite === undefined || !(t.input > 0)) return null;
129
+ const share = t.cacheWrite / t.input;
130
+ if (share < CACHE_WRITE_NOTE_SHARE) return null;
131
+ const head = `${Math.round(share * 100)}% of input written to the cache`;
132
+ let prev = -1;
133
+ for (let j = index - 1; j >= 0; j--) {
134
+ if (steps[j]!.agent === step.agent && steps[j]!.promptLayers) {
135
+ prev = j;
136
+ break;
137
+ }
138
+ }
139
+ if (prev < 0) return `${head} — the agent's first step in this run`;
140
+ const changed = promptLayersChanged(steps[prev]!.promptLayers, step.promptLayers);
141
+ if (!changed.length) return `${head} — the same prompt as step ${prev + 1}; the entry had expired or was still being written`;
142
+ return `${head} — ${changed.join(", ")} changed since step ${prev + 1}`;
143
+ }
package/src/tool-names.ts CHANGED
@@ -52,7 +52,7 @@ export const BUILTIN_TOOLS = new Set([
52
52
  /** The platform's own groups, served in-process rather than by the SDK.
53
53
  * `install` is granted to every agent anyway (install-tool.ts); naming it is
54
54
  * accepted so a file that says so is not flagged. */
55
- export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "install"]);
55
+ export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "peers", "install"]);
56
56
 
57
57
  /** Would this name resolve to something the runtime provides? Built-ins win a
58
58
  * clash, so a tool file of the same name is shadowed rather than granted. */
@@ -16,7 +16,7 @@
16
16
  import fs from "node:fs";
17
17
  import path from "node:path";
18
18
  import { builtinModules } from "node:module";
19
- import matter from "gray-matter";
19
+ import matter from "./matter.ts";
20
20
  import { parseRuntime } from "./runtime.ts";
21
21
 
22
22
  import { workspaceDir, workspaceTools, type ToolDef } from "./store.ts";