@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,48 @@
1
+ export interface RunFilter {
2
+ /** A step called a tool whose name contains this (`web`, `crm_export`). */
3
+ tool?: string;
4
+ /** A tool call errored. */
5
+ toolError?: boolean;
6
+ /** A step was this agent. */
7
+ agent?: string;
8
+ /** A step ran a model whose name contains this (`haiku`, `opus`). */
9
+ model?: string;
10
+ /** The run read at least this many input tokens, all steps together. */
11
+ minTokens?: number;
12
+ /** The run took at least this many seconds. */
13
+ minSecs?: number;
14
+ }
15
+ /** The query parameters a filter comes from: tool, tool_error, agent,
16
+ * model, min_tokens, min_secs. Unreadable numbers are refused in words. */
17
+ export declare function parseRunFilter(q: {
18
+ get(name: string): string | null;
19
+ }): {
20
+ filter: RunFilter;
21
+ problems: string[];
22
+ };
23
+ export declare const isEmptyFilter: (f: RunFilter) => boolean;
24
+ type FilterableStep = {
25
+ agent: string;
26
+ model?: string;
27
+ tools?: string[];
28
+ toolErrors?: number;
29
+ /** A whole record has events instead of the index's tools/toolErrors. */
30
+ events?: {
31
+ type: string;
32
+ text: string;
33
+ ms?: number;
34
+ err?: boolean;
35
+ }[];
36
+ tokens?: {
37
+ input?: number;
38
+ } | null;
39
+ startedAt?: string;
40
+ finishedAt?: string;
41
+ };
42
+ type FilterableRun = {
43
+ startedAt: string;
44
+ finishedAt?: string | null;
45
+ steps: FilterableStep[];
46
+ };
47
+ export declare function matchesRunFilter(run: FilterableRun, f: RunFilter, now?: number): boolean;
48
+ export {};
@@ -0,0 +1,73 @@
1
+ // Search over runs (FOL-72 step 9): one filter for the runs API, the runs
2
+ // pages and `foldrun runs`, read off the run index — the tools each step
3
+ // called, its tool errors, its agent and model, tokens and how long it took.
4
+ // Pure.
5
+ /** The query parameters a filter comes from: tool, tool_error, agent,
6
+ * model, min_tokens, min_secs. Unreadable numbers are refused in words. */
7
+ export function parseRunFilter(q) {
8
+ const filter = {};
9
+ const problems = [];
10
+ const text = (k) => q.get(k)?.trim() || undefined;
11
+ const tool = text("tool");
12
+ if (tool)
13
+ filter.tool = tool.toLowerCase();
14
+ const te = text("tool_error");
15
+ if (te)
16
+ filter.toolError = te === "1" || te === "true" || te === "yes";
17
+ const agent = text("agent");
18
+ if (agent)
19
+ filter.agent = agent;
20
+ const model = text("model");
21
+ if (model)
22
+ filter.model = model.toLowerCase();
23
+ for (const [k, field] of [["min_tokens", "minTokens"], ["min_secs", "minSecs"]]) {
24
+ const v = text(k);
25
+ if (v === undefined)
26
+ continue;
27
+ // "50k" and "1.5k" are thousands (Codex review: "1.5k" read as 1.5).
28
+ const kilo = /^(\d+(?:\.\d+)?)k$/i.exec(v);
29
+ const n = kilo ? Number(kilo[1]) * 1000 : Number(v);
30
+ if (!Number.isFinite(n) || n < 0)
31
+ problems.push(`${k} is a number (${k === "min_tokens" ? "50000 or 50k" : "seconds"}), not ${v}`);
32
+ else
33
+ filter[field] = n;
34
+ }
35
+ return { filter, problems };
36
+ }
37
+ export const isEmptyFilter = (f) => Object.keys(f).length === 0;
38
+ /** The index's facts, or worked out from a whole record's events (Codex
39
+ * review: the workspace runs page reads whole records). */
40
+ function factsOf(s) {
41
+ if (s.tools !== undefined || s.toolErrors !== undefined || !s.events)
42
+ return { tools: s.tools ?? [], toolErrors: s.toolErrors ?? 0 };
43
+ const tools = new Set();
44
+ let errs = 0;
45
+ for (const e of s.events) {
46
+ if (e.type !== "tool")
47
+ continue;
48
+ if (e.ms == null)
49
+ tools.add(e.text);
50
+ else if (e.err)
51
+ errs++;
52
+ }
53
+ return { tools: [...tools], toolErrors: errs };
54
+ }
55
+ export function matchesRunFilter(run, f, now = Date.now()) {
56
+ const steps = (run.steps ?? []).map((s) => ({ ...s, ...factsOf(s) }));
57
+ if (f.tool && !steps.some((s) => (s.tools ?? []).some((t) => t.toLowerCase().includes(f.tool))))
58
+ return false;
59
+ if (f.toolError !== undefined && steps.some((s) => (s.toolErrors ?? 0) > 0) !== f.toolError)
60
+ return false;
61
+ if (f.agent && !steps.some((s) => s.agent === f.agent))
62
+ return false;
63
+ if (f.model && !steps.some((s) => (s.model ?? "").toLowerCase().includes(f.model)))
64
+ return false;
65
+ if (f.minTokens !== undefined && steps.reduce((n, s) => n + (s.tokens?.input ?? 0), 0) < f.minTokens)
66
+ return false;
67
+ if (f.minSecs !== undefined) {
68
+ const secs = ((run.finishedAt ? Date.parse(run.finishedAt) : now) - Date.parse(run.startedAt)) / 1000;
69
+ if (!(secs >= f.minSecs))
70
+ return false;
71
+ }
72
+ return true;
73
+ }
@@ -25,6 +25,10 @@ export interface ScanFinding {
25
25
  message: string;
26
26
  /** Stable across edits elsewhere in the file: rule + file + line text. */
27
27
  fingerprint: string;
28
+ /** This rule had more to say in this file than PER_FILE_RULE: this
29
+ * finding counts the rest, so what is not listed is not resolved. Its
30
+ * fingerprint is of everything it counts, so a change among them is new. */
31
+ truncated?: true;
28
32
  }
29
33
  export interface ScanOptions {
30
34
  /** Tool names whose definition says `outward: true` — the CLI and the
@@ -66,11 +70,25 @@ export declare function redactSecret(secret: string): string;
66
70
  /** The text with every credential in it redacted — for a finding that
67
71
  * quotes source, so no message ever carries a key whole. */
68
72
  export declare function redactText(text: string): string;
73
+ /**
74
+ * Text from outside the workspace — what a remote MCP server says about its
75
+ * tools — through the content rules with nothing the text itself can turn
76
+ * off: no foldrun-ignore, no binary or vendored-path skip, no fences
77
+ * (FOL-58, Codex review: each of those let a server silence its own finding).
78
+ */
79
+ export declare function untrustedTextFindings(text: string): {
80
+ rule: string;
81
+ line: number;
82
+ message: string;
83
+ }[];
69
84
  /** The known field a key was probably meant to be, or null. */
70
85
  export declare function nearestField(key: string, known: readonly string[]): string | null;
86
+ /** How many times one rule is listed for one file before the rest are counted. */
87
+ export declare const PER_FILE_RULE = 100;
71
88
  /**
72
89
  * Every finding in these files, ignores applied. Findings are sorted by file
73
- * then line, and one rule says one thing once per line.
90
+ * then line, and one rule says one thing once per line, and at most
91
+ * PER_FILE_RULE times in one file.
74
92
  */
75
93
  export declare function scanFiles(files: ScanFile[], options?: ScanOptions): ScanFinding[];
76
94
  /**
package/dist/src/scan.js CHANGED
@@ -26,7 +26,7 @@
26
26
  // The reason is required: an ignore without one ignores nothing and is itself
27
27
  // reported, because "why is this allowed?" is the question a reviewer asks.
28
28
  import crypto from "node:crypto";
29
- import matter from "gray-matter";
29
+ import matter from "./matter.js";
30
30
  import { refNames } from "./refs.js";
31
31
  import { resolveNetwork } from "./network.js";
32
32
  import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
@@ -117,6 +117,24 @@ export const SCAN_RULES = [
117
117
  "so a run tomorrow uses what was tested today.",
118
118
  owasp: ["ASI04"],
119
119
  },
120
+ {
121
+ id: "supply-chain/known-vulnerability",
122
+ category: "supply-chain",
123
+ level: "warn",
124
+ title: "A pinned runtime package with known vulnerabilities",
125
+ help: "OSV.dev lists vulnerabilities for this exact version. Upgrade to a fixed one. An online check: `foldrun check --to <ws> --online`, " +
126
+ "Checks → Check online, and the daily rescan.",
127
+ owasp: ["ASI04"],
128
+ },
129
+ {
130
+ id: "content/mcp-tool-text",
131
+ category: "content",
132
+ level: "warn",
133
+ title: "An MCP server's tool text carries hidden or injected instructions",
134
+ help: "What a remote MCP server says about its tools reaches the model as instructions. Hidden characters or an order in a tool " +
135
+ "description is a server steering your agent. An online check, for servers reached by URL; a command server is never run to check it.",
136
+ owasp: ["ASI01", "ASI04"],
137
+ },
120
138
  {
121
139
  id: "config/unknown-field",
122
140
  category: "config",
@@ -399,13 +417,15 @@ const INJECTION = [
399
417
  /\b(?:send|post|upload|exfiltrate)\s+(?:all\s+|the\s+|every\s+)?(?:secrets?|credentials|api\s+keys?|tokens?|env(?:ironment)?\s+variables)\s+to\b/i,
400
418
  ];
401
419
  const BLOB = /[A-Za-z0-9+/]{300,}={0,2}/g;
402
- function scanContent(file, lines, push) {
403
- const md = /\.md$/i.test(file.path);
404
- const data = isDataDoc(file.path);
420
+ function scanContent(file, lines, push, untrusted = false) {
421
+ // Untrusted text (a remote server's) is read as data, every line: it
422
+ // cannot fence an order off as an "example" either.
423
+ const md = untrusted || /\.md$/i.test(file.path);
424
+ const data = untrusted || isDataDoc(file.path);
405
425
  let fenced = false;
406
426
  for (let i = 0; i < lines.length; i++) {
407
427
  const line = i === 0 ? lines[i].replace(/^/, "") : lines[i];
408
- if (/^\s*(?:```|~~~)/.test(line))
428
+ if (!untrusted && /^\s*(?:```|~~~)/.test(line))
409
429
  fenced = !fenced;
410
430
  HIDDEN.lastIndex = 0;
411
431
  if (HIDDEN.test(line)) {
@@ -431,6 +451,17 @@ function scanContent(file, lines, push) {
431
451
  }
432
452
  }
433
453
  }
454
+ /**
455
+ * Text from outside the workspace — what a remote MCP server says about its
456
+ * tools — through the content rules with nothing the text itself can turn
457
+ * off: no foldrun-ignore, no binary or vendored-path skip, no fences
458
+ * (FOL-58, Codex review: each of those let a server silence its own finding).
459
+ */
460
+ export function untrustedTextFindings(text) {
461
+ const out = [];
462
+ scanContent({ path: "untrusted", content: text }, text.split(/\r?\n/), (rule, _file, line, _text, message) => out.push({ rule, line: line ?? 0, message }), true);
463
+ return out;
464
+ }
434
465
  /** The text hidden in tag characters: each is U+E0000 + an ASCII code. */
435
466
  function decodeTags(s) {
436
467
  let out = "";
@@ -657,9 +688,12 @@ const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.
657
688
  function isText(f) {
658
689
  return !VENDORED.test(f.path) && !f.content.slice(0, 8000).includes("\u0000") && !/\.(?:png|jpe?g|gif|webp|ico|pdf|zip|gz|woff2?|ttf|mp[34]|mov|wasm)$/i.test(f.path);
659
690
  }
691
+ /** How many times one rule is listed for one file before the rest are counted. */
692
+ export const PER_FILE_RULE = 100;
660
693
  /**
661
694
  * Every finding in these files, ignores applied. Findings are sorted by file
662
- * then line, and one rule says one thing once per line.
695
+ * then line, and one rule says one thing once per line, and at most
696
+ * PER_FILE_RULE times in one file.
663
697
  */
664
698
  export function scanFiles(files, options = {}) {
665
699
  const want = options.only?.length ? new Set(options.only) : null;
@@ -667,6 +701,11 @@ export function scanFiles(files, options = {}) {
667
701
  const findings = [];
668
702
  const seen = new Set();
669
703
  const ignoresByFile = new Map();
704
+ // One rule in one file says it PER_FILE_RULE times, then counts the rest:
705
+ // a million lines of hidden characters is one problem, not a million
706
+ // findings held in memory (Codex review of the 20 Oct rescan outage).
707
+ const perFileRule = new Map();
708
+ const more = new Map();
670
709
  const push = (rule, file, line, text, message, level) => {
671
710
  if (!enabled(rule))
672
711
  return;
@@ -676,6 +715,16 @@ export function scanFiles(files, options = {}) {
676
715
  const key = `${rule}\0${file}\0${line ?? ""}\0${message}`;
677
716
  if (seen.has(key))
678
717
  return;
718
+ const rf = `${rule}\0${file}`;
719
+ const said = perFileRule.get(rf) ?? 0;
720
+ if (said >= PER_FILE_RULE) {
721
+ const m = more.get(rf) ?? { rule, file, level, n: 0, hash: crypto.createHash("sha256") };
722
+ m.n++;
723
+ m.hash.update(`${findingFingerprint(rule, file, text)}\0`);
724
+ more.set(rf, m);
725
+ return;
726
+ }
727
+ perFileRule.set(rf, said + 1);
679
728
  seen.add(key);
680
729
  findings.push({ rule, level: level ?? scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
681
730
  };
@@ -719,8 +768,12 @@ export function scanFiles(files, options = {}) {
719
768
  toolDefs.set(f.path, { data, lines });
720
769
  }
721
770
  }
722
- const workspaceMd = files.find((f) => f.path === "AGENTS.md");
771
+ // project.md is the older name the runtime still reads (Codex review, FOL-58).
772
+ const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
723
773
  scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
774
+ for (const m of more.values()) {
775
+ findings.push({ rule: m.rule, level: m.level ?? scanRule(m.rule)?.level ?? "warn", file: m.file, message: `and ${m.n} more like this in this file, not listed`, fingerprint: m.hash.digest("hex").slice(0, 16), truncated: true });
776
+ }
724
777
  return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
725
778
  }
726
779
  /**
@@ -357,7 +357,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
357
357
  | \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
358
358
 
359
359
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
360
- \`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
360
+ \`/api/approvals\`, \`/api/issues\` (what went wrong, open until resolved), \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
361
361
  \`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
362
362
  \`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
363
363
  \`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
@@ -391,6 +391,7 @@ subflow), \`foldrun flow rm-step\` removes a step, and \`foldrun agent link
391
391
  trigger: manual # manual | schedule | webhook
392
392
  schedule: "0 9 * * 1" # cron, when trigger: schedule
393
393
  timezone: Australia/Sydney
394
+ owner: ana@example.com # who answers for it; else the workspace's, else the account's
394
395
  model: default # a default for every step here
395
396
  effort: low
396
397
  overlap: skip # skip | queue — what a second run does while one is live
@@ -448,10 +449,11 @@ by a model: what this role is for, what it must not do, what good looks like.
448
449
  ---
449
450
  name: researcher
450
451
  description: Finds and summarises sources.
452
+ owner: ana@example.com # who answers for it; else the workspace's, else the account's
451
453
  model: default # fast | default | max
452
454
  effort: low # how hard to think about it
453
455
  size: large # small | large | heavy — the sandbox it rents
454
- tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
456
+ tools: [web, read, ask, peers, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step, \`peers\` lets it message the steps running beside it
455
457
  disallowedTools: [Bash] # subtract from what it would otherwise have
456
458
  skills: [house-style] # from skills/
457
459
  hooks: [no-force-push] # from hooks/ or the library: code at every tool call
@@ -1,6 +1,6 @@
1
1
  import { type HookDef, type HookRecord } from "./hooks.ts";
2
2
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
3
- import type { Effort } from "./store.ts";
3
+ import { type Effort, type ModelCall } from "./store.ts";
4
4
  import { type TokenPrice } from "./catalog.ts";
5
5
  import type { TestEffect } from "./test-mode.ts";
6
6
  import type { OperatorEvent } from "./operator.ts";
@@ -57,6 +57,11 @@ export interface ExecOutcome {
57
57
  browserPod?: BrowserPodOutcome;
58
58
  }
59
59
  export interface ExecOptions {
60
+ /** What the step reports beyond its events (FOL-54 step 4): `metadata`
61
+ * sends each model call (`turn`); `full` also sends each tool call's input
62
+ * and output (`io`). Unset sends neither — the consult, the probe and any
63
+ * caller that does not store them. */
64
+ capture?: "metadata" | "full";
60
65
  /** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
61
66
  * inside its sandbox. `hookBase` is what every hook event carries. */
62
67
  hooks?: HookDef[];
@@ -175,7 +180,30 @@ export type EventExtra = {
175
180
  subagent?: string;
176
181
  check?: boolean;
177
182
  hook?: HookRecord;
183
+ /** A tool call's content, on its way to the runner (FOL-54 step 4): the
184
+ * runner masks it, stores it beside the run and keeps only `captured` on
185
+ * the event. Sent only under `capture: "full"`. */
186
+ io?: CallIo;
187
+ /** One model call, on its way to the runner's `step.calls` — not an event. */
188
+ turn?: ModelCall;
189
+ /** The model's first response of the step has begun streaming — the
190
+ * moment the prompt cache entry its request wrote becomes readable. Sent
191
+ * once; the runner's fan-out gate reads it. Not an event. */
192
+ responding?: true;
193
+ /** Set by the runner once it stored `io` (RunEvent.captured). */
194
+ captured?: boolean;
195
+ /** On a tool call: the model call (its message id) that asked for it. */
196
+ inCall?: string;
178
197
  };
198
+ /** A tool call's input (on the call) or output (on its completion), capped. */
199
+ export interface CallIo {
200
+ input?: string;
201
+ output?: string;
202
+ /** The whole input's / output's length, in characters, before any cut. */
203
+ inputChars?: number;
204
+ outputChars?: number;
205
+ truncated?: boolean;
206
+ }
179
207
  /** Conservative per-token rates for a model nothing else can price — an
180
208
  * unknown id on a gateway with no catalogue. Opus-class, so a ceiling
181
209
  * still means something. Only ever reached when `knownPrice` below does
@@ -12,13 +12,14 @@ import fs from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
14
  import { query } from "@anthropic-ai/claude-agent-sdk";
15
+ import { CAPTURE_CAP } from "./store.js";
15
16
  import { costOf } from "./catalog.js";
16
17
  import { spawn } from "node:child_process";
17
18
  import { checkPaths, checkBash, isFilesystemTool, linkWorkspace, workspaceLinkEnv, resolveAgentPath, isWithin } from "./confine.js";
18
19
  import { DELEGATE_TOOLS, subagentGuard, toAgentDefinitions } from "./subagents.js";
19
20
  import { hostSafeEnv } from "./host-env.js";
20
21
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
21
- import { CallCounter, limitKeysFor } from "./limits.js";
22
+ import { CallCounter, callKeysFor } from "./limits.js";
22
23
  import { classifyCall, podAt, readPodEvents } from "./browser-pod.js";
23
24
  /** The four counts from one SDK usage object (snake_case, as the API
24
25
  * reports it). The API counts fresh input apart from the cache's; here
@@ -50,6 +51,14 @@ export function verifyBudgetMs(timeoutSec, elapsedMs) {
50
51
  /** After an interrupt is asked for, how long the model loop gets to wind
51
52
  * down on its own before the query is aborted outright. */
52
53
  export const INTERRUPT_GRACE_MS = 10_000;
54
+ // The cap plus slack: the runner masks, then cuts to the cap (capture.ts), so
55
+ // a secret on the edge is masked whole.
56
+ const SEND_CAP = CAPTURE_CAP + 4096;
57
+ function capped(text) {
58
+ if (text.length <= SEND_CAP)
59
+ return { text, chars: text.length, truncated: false };
60
+ return { text: text.slice(0, SEND_CAP), chars: text.length, truncated: true };
61
+ }
53
62
  /** The tag on every event a failed check writes — `verify:`, `output: json`,
54
63
  * a schema — headline and detail alike. The runner's no-retry rule for
55
64
  * outward steps reads it (actedThenFailedCheck); reading the wording of the
@@ -228,6 +237,10 @@ async function executeStepInner(opts, runQuery) {
228
237
  ...(opts.effort ? { effort: opts.effort } : {}),
229
238
  systemPrompt: opts.systemPrompt,
230
239
  ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
240
+ // The raw stream, only when model calls are recorded: an assistant
241
+ // message carries usage from the call's start (output a few tokens) and
242
+ // no stop reason; the call's message_delta has both (FOL-72).
243
+ ...(opts.capture ? { includePartialMessages: true } : {}),
231
244
  // Sub-agents: the SDK's own delegation. Every definition carries an
232
245
  // explicit tool list (an absent one inherits all of ours).
233
246
  ...(subagents ? { agents: toAgentDefinitions(subagents) } : {}),
@@ -313,7 +326,7 @@ async function executeStepInner(opts, runQuery) {
313
326
  // refused before it runs: the tool, and any paid API behind it,
314
327
  // never sees it.
315
328
  if (counter.active) {
316
- const refusal = counter.take(limitKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners));
329
+ const refusal = counter.take(callKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners));
317
330
  if (refusal) {
318
331
  emit("info", `${refusal.message} (${hookInput.tool_name} refused)`, via ? { subagent: via } : undefined);
319
332
  return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal.message } };
@@ -330,7 +343,7 @@ async function executeStepInner(opts, runQuery) {
330
343
  for (const r of v.records)
331
344
  record(r, via);
332
345
  const refundCall = () => { if (counter.active)
333
- counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners)); };
346
+ counter.refund(callKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners)); };
334
347
  if (v.decision === "deny") {
335
348
  refundCall();
336
349
  return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: v.reason } };
@@ -407,7 +420,7 @@ async function executeStepInner(opts, runQuery) {
407
420
  const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
408
421
  // A call the hook counted and this refuses never ran: give it back.
409
422
  const refund = () => { if (counter.active)
410
- counter.refund(limitKeysFor(toolName, input, opts.toolOwners)); };
423
+ counter.refund(callKeysFor(toolName, input, opts.toolOwners)); };
411
424
  if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
412
425
  refund();
413
426
  return {
@@ -480,6 +493,29 @@ async function executeStepInner(opts, runQuery) {
480
493
  // ceiling and on the bill.
481
494
  const turns = new Map();
482
495
  let anonymousTurns = 0;
496
+ // Each model call as a span (FOL-54 step 4), keyed like `turns`: a turn's
497
+ // blocks arrive as several messages under one id, so it is updated in place
498
+ // and sent again; the runner keeps the latest by id.
499
+ const spans = new Map();
500
+ // From the raw stream: which call each agent is in (message_start), and each
501
+ // call's final output tokens and stop reason (message_delta).
502
+ const streaming = new Map();
503
+ let saidResponding = false;
504
+ const finals = new Map();
505
+ const withFinal = (span) => {
506
+ const f = finals.get(span.id);
507
+ return {
508
+ ...span,
509
+ ...(f?.output !== undefined ? { tokens: { ...span.tokens, output: f.output } } : {}),
510
+ // Without the stream (an older CLI), a call that used tools stopped to use them.
511
+ ...(f?.stop ? { stop: f.stop } : !span.stop && span.tools > 0 ? { stop: "tool_use" } : {}),
512
+ };
513
+ };
514
+ // When each agent's next request went out — the step's own, and each
515
+ // sub-agent's apart, so one's tool results do not restart another's clock.
516
+ const lastInputAt = new Map();
517
+ const sentAt = (via) => lastInputAt.get(via ?? "") ?? loopStartedAt;
518
+ let loopStartedAt = Date.now();
483
519
  const specByName = new Map((subagents ?? []).map((s) => [s.name, s]));
484
520
  const priceOf = (message) => {
485
521
  const parent = message.parent_tool_use_id;
@@ -560,13 +596,65 @@ async function executeStepInner(opts, runQuery) {
560
596
  };
561
597
  try {
562
598
  for await (const message of q) {
599
+ // The session is up: the first model call is timed from here, not from
600
+ // before the SDK and its MCP servers started.
601
+ if (message.type === "system" && message.subtype === "init")
602
+ loopStartedAt = Date.now();
603
+ if (message.type === "stream_event") {
604
+ const ev = message.event;
605
+ const agentKey = message.parent_tool_use_id ?? "";
606
+ if (ev?.type === "message_start" && ev.message?.id) {
607
+ streaming.set(agentKey, ev.message.id);
608
+ // The step's own first response has begun: its prompt is in the
609
+ // cache now, so siblings held back to read it may start.
610
+ if (!agentKey && !saidResponding) {
611
+ saidResponding = true;
612
+ emit("info", "", { responding: true });
613
+ }
614
+ }
615
+ if (ev?.type === "message_delta") {
616
+ const id = streaming.get(agentKey);
617
+ if (id) {
618
+ finals.set(id, {
619
+ ...(typeof ev.usage?.output_tokens === "number" ? { output: ev.usage.output_tokens } : {}),
620
+ ...(ev.delta?.stop_reason ? { stop: ev.delta.stop_reason } : {}),
621
+ });
622
+ const span = spans.get(id);
623
+ if (span) {
624
+ const final = withFinal(span);
625
+ spans.set(id, final);
626
+ emit("info", "", { turn: final });
627
+ }
628
+ }
629
+ }
630
+ continue;
631
+ }
563
632
  if (ended)
564
633
  break;
565
634
  if (message.type === "assistant") {
566
635
  const m = message.message;
567
636
  const u = m.usage;
637
+ const turnKey = m.id ?? `turn-${anonymousTurns++}`;
568
638
  if (u)
569
- turns.set(m.id ?? `turn-${anonymousTurns++}`, { u, price: priceOf(message) });
639
+ turns.set(turnKey, { u, price: priceOf(message) });
640
+ if (opts.capture && u) {
641
+ const t = usageFromSdk(u);
642
+ const prior = spans.get(turnKey);
643
+ const span = {
644
+ id: turnKey,
645
+ n: prior?.n ?? spans.size + 1,
646
+ at: prior?.at ?? new Date().toISOString(),
647
+ ms: prior?.ms ?? Date.now() - sentAt(subagentOf(message)),
648
+ ...(m.model ? { model: m.model } : prior?.model ? { model: prior.model } : {}),
649
+ ...(m.stop_reason ? { stop: m.stop_reason } : prior?.stop ? { stop: prior.stop } : {}),
650
+ tokens: { input: t.inputTokens, output: t.outputTokens, cacheRead: t.cacheReadTokens, cacheWrite: t.cacheWriteTokens },
651
+ tools: (prior?.tools ?? 0) + (m.content ?? []).filter((b) => b?.type === "tool_use").length,
652
+ ...(subagentOf(message) ? { subagent: subagentOf(message) } : {}),
653
+ };
654
+ const final = withFinal(span);
655
+ spans.set(turnKey, final);
656
+ emit("info", "", { turn: final });
657
+ }
570
658
  if (ceiling && u) {
571
659
  spentUsd = turnsTotal();
572
660
  if (spentUsd >= ceiling) {
@@ -590,6 +678,8 @@ async function executeStepInner(opts, runQuery) {
590
678
  const input = block.input;
591
679
  const to = String(input?.subagent_type ?? "subagent");
592
680
  delegations.set(block.id, to);
681
+ // That sub-agent's first model call is timed from its job going out.
682
+ lastInputAt.set(to, Date.now());
593
683
  // One line a person can read: who got the job, and what it was.
594
684
  const job = String(input?.description ?? input?.prompt ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
595
685
  emit("info", `delegating to ${to}${job ? `: ${job}` : ""}`, { subagent: to });
@@ -597,7 +687,13 @@ async function executeStepInner(opts, runQuery) {
597
687
  openCalls.set(block.id, { name: block.name, at: Date.now(), ...(via ? { subagent: via } : {}) });
598
688
  if (pod)
599
689
  ledger.set(block.id, classifyCall(block.name, block.input, pod));
600
- emit("tool", block.name, { call: block.id, ...(via ? { subagent: via } : {}) });
690
+ const input = opts.capture === "full" ? capped(JSON.stringify(block.input ?? {})) : null;
691
+ emit("tool", block.name, {
692
+ call: block.id,
693
+ ...(opts.capture ? { inCall: turnKey } : {}),
694
+ ...(via ? { subagent: via } : {}),
695
+ ...(input ? { io: { input: input.text, inputChars: input.chars, ...(input.truncated ? { truncated: true } : {}) } } : {}),
696
+ });
601
697
  }
602
698
  }
603
699
  }
@@ -613,6 +709,7 @@ async function executeStepInner(opts, runQuery) {
613
709
  if (!open)
614
710
  continue;
615
711
  openCalls.delete(block.tool_use_id);
712
+ const output = opts.capture === "full" ? capped(toolResultText(block.content)) : null;
616
713
  emit("tool", open.name, {
617
714
  call: block.tool_use_id,
618
715
  ms: Date.now() - open.at,
@@ -621,6 +718,7 @@ async function executeStepInner(opts, runQuery) {
621
718
  bytes: toolResultText(block.content).length,
622
719
  ...(block.is_error ? { err: true } : {}),
623
720
  ...(open.subagent ? { subagent: open.subagent } : {}),
721
+ ...(output ? { io: { output: output.text, outputChars: output.chars, ...(output.truncated ? { truncated: true } : {}) } } : {}),
624
722
  });
625
723
  // Why it failed, on the trail. The span above carried only `err`,
626
724
  // so a refused call left the agent's own guess as the only account
@@ -633,6 +731,8 @@ async function executeStepInner(opts, runQuery) {
633
731
  }
634
732
  readPod(block.tool_use_id);
635
733
  }
734
+ // That agent's next model call is timed from here: the results went back.
735
+ lastInputAt.set(subagentOf(message) ?? "", Date.now());
636
736
  }
637
737
  }
638
738
  else if (message.type === "result") {