@foldrun/core 9.0.0 → 9.2.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 (65) hide show
  1. package/dist/src/a2a-client.d.ts +165 -0
  2. package/dist/src/a2a-client.js +440 -0
  3. package/dist/src/a2a-publish.d.ts +37 -0
  4. package/dist/src/a2a-publish.js +116 -0
  5. package/dist/src/a2a-remotes.d.ts +73 -0
  6. package/dist/src/a2a-remotes.js +192 -0
  7. package/dist/src/a2a-tools.d.ts +148 -0
  8. package/dist/src/a2a-tools.js +833 -0
  9. package/dist/src/api-tools.js +15 -10
  10. package/dist/src/approvals.d.ts +18 -10
  11. package/dist/src/approvals.js +51 -1
  12. package/dist/src/browser-pod.js +4 -0
  13. package/dist/src/catalog.d.ts +14 -0
  14. package/dist/src/catalog.js +28 -0
  15. package/dist/src/completions.js +8 -8
  16. package/dist/src/cron.d.ts +9 -0
  17. package/dist/src/cron.js +20 -0
  18. package/dist/src/frontmatter-fields.js +3 -1
  19. package/dist/src/frontmatter-schema.d.ts +5 -1
  20. package/dist/src/frontmatter-schema.js +44 -1
  21. package/dist/src/limits.js +22 -4
  22. package/dist/src/notify.d.ts +6 -3
  23. package/dist/src/notify.js +25 -12
  24. package/dist/src/package.d.ts +5 -1
  25. package/dist/src/package.js +5 -3
  26. package/dist/src/run-container.d.ts +10 -1
  27. package/dist/src/run-container.js +17 -0
  28. package/dist/src/runner.d.ts +10 -0
  29. package/dist/src/runner.js +163 -16
  30. package/dist/src/scan.js +123 -5
  31. package/dist/src/secrets.d.ts +122 -10
  32. package/dist/src/secrets.js +291 -27
  33. package/dist/src/starter.js +5 -1
  34. package/dist/src/store.d.ts +34 -0
  35. package/dist/src/store.js +28 -7
  36. package/dist/src/subagents.d.ts +16 -0
  37. package/dist/src/subagents.js +23 -2
  38. package/dist/src/triggers.js +2 -2
  39. package/dist/src/webhook.d.ts +19 -3
  40. package/dist/src/webhook.js +14 -5
  41. package/package.json +4 -1
  42. package/src/a2a-client.ts +517 -0
  43. package/src/a2a-publish.ts +120 -0
  44. package/src/a2a-remotes.ts +225 -0
  45. package/src/a2a-tools.ts +919 -0
  46. package/src/api-tools.ts +20 -8
  47. package/src/approvals.ts +55 -1
  48. package/src/browser-pod.ts +3 -0
  49. package/src/catalog.ts +27 -0
  50. package/src/completions.ts +8 -8
  51. package/src/cron.ts +19 -0
  52. package/src/frontmatter-fields.ts +3 -1
  53. package/src/frontmatter-schema.ts +42 -2
  54. package/src/limits.ts +22 -4
  55. package/src/notify.ts +23 -12
  56. package/src/package.ts +5 -3
  57. package/src/run-container.ts +22 -0
  58. package/src/runner.ts +157 -13
  59. package/src/scan.ts +146 -6
  60. package/src/secrets.ts +319 -33
  61. package/src/starter.ts +5 -1
  62. package/src/store.ts +47 -2
  63. package/src/subagents.ts +29 -2
  64. package/src/triggers.ts +2 -2
  65. package/src/webhook.ts +22 -5
package/src/store.ts CHANGED
@@ -50,6 +50,8 @@ export function readFrontmatter(file: string): Record<string, unknown> {
50
50
  import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.ts";
51
51
  import { refNames } from "./refs.ts";
52
52
  import { checkFilter } from "./trigger-filter.ts";
53
+ import { parseA2aPublish, type A2aPublication } from "./a2a-publish.ts";
54
+ import { parseRemotes, remoteNetworkHosts, A2A_LIMIT_KEY, type RemoteSpec } from "./a2a-remotes.ts";
53
55
  import { parseBudget, budgetProblem } from "./budget.ts";
54
56
  import { timezoneProblem } from "./clock.ts";
55
57
  import { parseStepLimits, readLimits } from "./limits.ts";
@@ -984,6 +986,9 @@ export interface AgentInfo {
984
986
  effort: string | null;
985
987
  tools: string[];
986
988
  apis: ApiSpec[];
989
+ /** `remotes:` — the outside A2A agents it may call, the entries that read
990
+ * (a2a-remotes.ts). Never a token: names, card URLs, secret NAMES. */
991
+ remotes?: RemoteSpec[];
987
992
  /** The author's own tools named in `tools:` — the subset that is not a
988
993
  * built-in. Same list as `tools`, minus what the runtime provides. */
989
994
  ownTools: string[];
@@ -1518,6 +1523,11 @@ export interface FlowInfo {
1518
1523
  /** Which of the three `owner` came from. */
1519
1524
  ownerFrom: OwnerSource | null;
1520
1525
 
1526
+ /** `a2a:` — whether outside agents may call this flow over A2A, and what
1527
+ * its agent card says (a2a-publish.ts). Null when absent or unreadable:
1528
+ * a block with a problem publishes nothing. */
1529
+ a2a?: A2aPublication | null;
1530
+
1521
1531
  /** Flow-level frontmatter values the parser could not read — a bad
1522
1532
  * `overlap:`/`priority:`/`catchup:`/`on:`/`signature:`, or a step line
1523
1533
  * whose `[[target]]` is malformed. Errors, the way a bad step option is,
@@ -1604,6 +1614,8 @@ function inlineToolNames(data: Record<string, unknown>): string[] {
1604
1614
  }
1605
1615
  }
1606
1616
  if (data.mcpServers && typeof data.mcpServers === "object") out.push(...Object.keys(data.mcpServers));
1617
+ // remotes: grants the a2a tools, counted under `limits: {a2a: N}`.
1618
+ if (parseRemotes(data.remotes).length) out.push(A2A_LIMIT_KEY);
1607
1619
  return out;
1608
1620
  }
1609
1621
 
@@ -1645,7 +1657,11 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1645
1657
  workspace: workspaceFront.network,
1646
1658
  agent: data.network,
1647
1659
  readsWeb,
1648
- implied: parseApis(data.apis).map((a) => { try { return new URL(a.base).host; } catch { return ""; } }),
1660
+ implied: [
1661
+ ...parseApis(data.apis).map((a) => { try { return new URL(a.base).host; } catch { return ""; } }),
1662
+ // remotes: — each card's own host, and nothing its card says.
1663
+ ...remoteNetworkHosts(parseRemotes(data.remotes)),
1664
+ ],
1649
1665
  }),
1650
1666
  readsWeb,
1651
1667
  };
@@ -1658,6 +1674,7 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1658
1674
  effort: data.effort ?? null,
1659
1675
  tools,
1660
1676
  apis: parseApis(data.apis),
1677
+ remotes: parseRemotes(data.remotes),
1661
1678
  ownTools: ownToolNames(data),
1662
1679
  legacyUse: legacyUseNames(data),
1663
1680
  retiredTools: retiredToolNames(data),
@@ -2138,6 +2155,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
2138
2155
  approveWithin: durationOf(data.approve_within),
2139
2156
  description: typeof data.description === "string" ? data.description.trim() || null : null,
2140
2157
  ...resolveOwner(data.owner, undefined, undefined, "flow"),
2158
+ a2a: parseA2aPublish(data.a2a),
2141
2159
  frontProblems,
2142
2160
  steps,
2143
2161
  };
@@ -2985,7 +3003,9 @@ export function renameWorkspaceFile(tenant: string, workspace: string, from: str
2985
3003
  // Refuse with the reason before any path validation: "not an editable
2986
3004
  // path" is true of AGENTS.md too, and the generic sentence buries the
2987
3005
  // specific one worth reading.
2988
- const why = anchoredReason(from) ?? anchoredReason(to);
3006
+ // Read as the paths the rename will use: `./AGENTS.md` is AGENTS.md
3007
+ // (1 Nov 2026 — the raw spelling slipped past this and still moved it).
3008
+ const why = anchoredReason(path.normalize(from)) ?? anchoredReason(path.normalize(to));
2989
3009
  if (why) throw new Error(why);
2990
3010
  const dir = workspaceDir(tenant, workspace);
2991
3011
  const src = path.join(dir, assertEditablePath(from));
@@ -3740,6 +3760,11 @@ export interface RunRecord {
3740
3760
  * expressed only as a status, because "failed" and "someone stopped it"
3741
3761
  * are different facts and the trace should not conflate them. */
3742
3762
  stopRequested?: boolean;
3763
+ /** Who stopped it, as the trail says it, when that was not a person
3764
+ * stopping it from the dashboard, the CLI or the API: "A2A caller
3765
+ * <key label>" for an A2A CancelTask (1 Nov 2026). Absent: a person.
3766
+ * Written by stopRun beside stopRequested, carried the same way. */
3767
+ stoppedBy?: string;
3743
3768
  /**
3744
3769
  * What this run concluded, in one line — the first line of the last step
3745
3770
  * that produced a result, captured when the run finishes.
@@ -3810,6 +3835,14 @@ export interface RunRecord {
3810
3835
  * step of this run — never the tokens. A link is good once; the second
3811
3836
  * click is a 409, not a second approval. Written by approvals.ts. */
3812
3837
  approvalLinksUsed?: string[];
3838
+ /** What started it, when that is not a person, a schedule or an event the
3839
+ * flow's own trigger names: "a2a" — an outside agent, over A2A (1 Nov
3840
+ * 2026). Absent for every other run. */
3841
+ trigger?: "a2a";
3842
+ /** For an A2A run: the A2A task it serves and the id of the A2A key (its
3843
+ * first id, kept across rotations) that asked. A correlation only — the
3844
+ * protocol's own state lives in the platform's a2a_tasks. */
3845
+ a2a?: { task: string; key: string };
3813
3846
  steps: StepRecord[];
3814
3847
  }
3815
3848
 
@@ -4089,6 +4122,9 @@ export interface RunSummary {
4089
4122
  revision?: string | null;
4090
4123
  /** A step is parked for a person — what the approvals banner counts. */
4091
4124
  awaitingApproval: boolean;
4125
+ /** RunRecord.trigger and .a2a: an outside agent started it. */
4126
+ trigger?: "a2a";
4127
+ a2a?: { task: string; key: string };
4092
4128
  steps: RunSummaryStep[];
4093
4129
  }
4094
4130
 
@@ -4131,6 +4167,8 @@ export function summarizeRun(run: RunRecord): RunSummary {
4131
4167
  ...(run.startedBy ? { startedBy: run.startedBy } : {}),
4132
4168
  ...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
4133
4169
  ...(run.revision ? { revision: run.revision } : {}),
4170
+ ...(run.trigger ? { trigger: run.trigger } : {}),
4171
+ ...(run.a2a ? { a2a: run.a2a } : {}),
4134
4172
  awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
4135
4173
  steps: run.steps.map((s) => {
4136
4174
  const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
@@ -4319,6 +4357,13 @@ export function listAllRunSummaries(tenant: string): (RunSummary & { workspace:
4319
4357
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
4320
4358
  }
4321
4359
 
4360
+ /** Who stopped a run, for a trail line: "a person" unless stopRun was told
4361
+ * otherwise (RunRecord.stoppedBy). */
4362
+ export function stoppedByText(run: Pick<RunRecord, "stoppedBy"> | null | undefined): string {
4363
+ const by = run?.stoppedBy?.trim();
4364
+ return by ? by : "a person";
4365
+ }
4366
+
4322
4367
  /**
4323
4368
  * The status a person should see. "failed" and "someone stopped it" are
4324
4369
  * different facts — the record keeps them apart (stopRequested), and the
package/src/subagents.ts CHANGED
@@ -28,6 +28,24 @@ import fs from "node:fs";
28
28
  import path from "node:path";
29
29
  import { refNames } from "./refs.ts";
30
30
  import { TOOL_MAP } from "./tool-names.ts";
31
+ import { A2A_SERVER } from "./a2a-remotes.ts";
32
+
33
+ /**
34
+ * The a2a tools (remotes:) are the parent's: one server, built for the
35
+ * PARENT's remotes with the parent's credentials, under the same four names
36
+ * whatever remotes a sub-agent declares. Intersecting names would hand a
37
+ * shared-sandbox sub-agent every remote its parent may call. So they are
38
+ * withheld from it entirely — not in its list, and refused if it calls one.
39
+ * A `sandbox: own` sub-agent runs as a step of its own, from its own file,
40
+ * and gets its OWN remotes there (runner.ts delegateFor → runStep).
41
+ */
42
+ export function isA2aTool(tool: string): boolean {
43
+ return tool === `mcp__${A2A_SERVER}` || tool.startsWith(`mcp__${A2A_SERVER}__`);
44
+ }
45
+
46
+ /** What a shared-sandbox sub-agent is told, and what its refused call says. */
47
+ export const SUBAGENT_NO_REMOTES =
48
+ "remotes: are not available to shared-sandbox sub-agents yet — give the sub-agent `sandbox: own`, or call the remote from the parent";
31
49
 
32
50
  /** Plain JSON: this crosses into the run container. */
33
51
  export interface SubagentSpec {
@@ -95,7 +113,7 @@ export function subagentTools(own: string[], parent: string[]): string[] {
95
113
  );
96
114
  const out: string[] = [];
97
115
  for (const t of own) {
98
- if ((DELEGATE_TOOLS as readonly string[]).includes(t) || out.includes(t)) continue;
116
+ if ((DELEGATE_TOOLS as readonly string[]).includes(t) || isA2aTool(t) || out.includes(t)) continue;
99
117
  const server = /^mcp__([^_].*?)__/.exec(t)?.[1];
100
118
  if (held.has(t) || (server && servers.has(`mcp__${server}`))) out.push(t);
101
119
  }
@@ -110,9 +128,16 @@ export function subagentPrompt(o: {
110
128
  parent: string;
111
129
  workspace: string;
112
130
  shared?: string | null;
113
- }): string {
131
+ }, extra: {
132
+ /** It declares remotes:, which it does not get here. A second argument,
133
+ * so the signature only widened. */
134
+ remotesWithheld?: boolean;
135
+ } = {}): string {
114
136
  return [
115
137
  o.body.trim(),
138
+ ...(extra.remotesWithheld
139
+ ? [`# Outside agents\n\nYour remotes: are not available to you as ${o.parent}'s sub-agent: ${SUBAGENT_NO_REMOTES}. If the job needs an outside agent, say so in your reply and ${o.parent} can call it.`]
140
+ : []),
116
141
  `# Where you are\n\n` +
117
142
  `You are ${o.name}, a sub-agent started by ${o.parent} to do one job and report back. ` +
118
143
  `You work in ${o.parent}'s directory, \`agents/${o.parent}/\` in the \`${o.workspace}\` workspace: ` +
@@ -183,6 +208,8 @@ export function subagentGuard(specs: SubagentSpec[]): (call: GuardedCall) => str
183
208
  if (isDelegate(tool)) {
184
209
  return `${s.name} is a sub-agent and cannot start another — do the work with your own tools`;
185
210
  }
211
+ // The parent's remotes, with the parent's credentials: never a sub-agent's.
212
+ if (isA2aTool(tool)) return `${tool} is not available to ${s.name}: ${SUBAGENT_NO_REMOTES}`;
186
213
  if (s.disallowedTools.includes(tool)) return `${tool} is disallowed for ${s.name}`;
187
214
  if (subagentTools([tool], s.tools).length === 0) return `${tool} is not one of ${s.name}'s tools`;
188
215
  return null;
package/src/triggers.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import crypto from "node:crypto";
16
16
  import { platform } from "./platform.ts";
17
17
  import { listFlows, readRun, runVerdict, type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
18
- import { getSecret } from "./secrets.ts";
18
+ import { getSecret, signingSecretShown } from "./secrets.ts";
19
19
 
20
20
  /** The last step's result — the same reading runner.ts makes, kept local so
21
21
  * this module never imports the runner (the queue imports both). */
@@ -273,7 +273,7 @@ export function verifyWebhookSignature(
273
273
  if (!flow.signature) return { ok: true, reason: "no signature required" };
274
274
  if (!flow.signingSecret) return { ok: false, reason: `signature: ${flow.signature} needs signing_secret: \${NAME}` };
275
275
  const secret = getSecret(tenant, flow.signingSecret, workspace);
276
- if (!secret) return { ok: false, reason: `signing secret ${flow.signingSecret} is not set` };
276
+ if (!secret) return { ok: false, reason: `signing secret ${signingSecretShown(flow.signingSecret)} is not set` };
277
277
  const hmac = (data: string) => crypto.createHmac("sha256", secret.value).update(data).digest("hex");
278
278
  const fresh = (ts: number) => Math.abs(now / 1000 - ts) <= 300;
279
279
 
package/src/webhook.ts CHANGED
@@ -96,22 +96,39 @@ export function approveToken(tenant: string, workspace: string, runId: string):
96
96
  .slice(0, 32);
97
97
  }
98
98
 
99
+ /** One parked `wait: event` gate: the step's index in the run record and
100
+ * the moment it parked (the `t` of its "waiting for an external event"
101
+ * trace line). */
102
+ export interface EventGate {
103
+ step: number;
104
+ at: string;
105
+ }
106
+
99
107
  /**
100
108
  * The token that releases a run parked on `wait: event`. Its own HMAC
101
109
  * family, so an approval link cannot double as an event URL or the reverse.
110
+ *
111
+ * With `gate`, the token is for that one park of that one step (30 Oct
112
+ * 2026). The per-run token released every later `wait: event` of the run,
113
+ * so a URL handed to a third party for the first gate also opened the
114
+ * second, and never expired. A gate's token dies with its park: the step
115
+ * released is no longer waiting, and parking again (a loop) stamps a new
116
+ * `at`. The per-run form, without `gate`, is what URLs printed before then
117
+ * carry — the events route still takes it for a step parked under it.
102
118
  */
103
- export function eventToken(tenant: string, workspace: string, runId: string): string {
119
+ export function eventToken(tenant: string, workspace: string, runId: string, gate?: EventGate): string {
104
120
  return crypto
105
121
  .createHmac("sha256", installKey())
106
- .update(`event:${tenant}/${workspace}/${runId}`)
122
+ .update(gate ? `event:${tenant}/${workspace}/${runId}#${gate.step}@${gate.at}` : `event:${tenant}/${workspace}/${runId}`)
107
123
  .digest("hex")
108
124
  .slice(0, 32);
109
125
  }
110
126
 
111
127
  /** The URL a `wait: event` step is released at — absolute when the install
112
- * knows its origin, a path otherwise. */
113
- export function eventUrl(tenant: string, workspace: string, runId: string): string {
114
- const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId)}`;
128
+ * knows its origin, a path otherwise. With `gate`, that step's own URL
129
+ * (`&step=<index>`); the route finds the park's moment on the record. */
130
+ export function eventUrl(tenant: string, workspace: string, runId: string, gate?: EventGate): string {
131
+ const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId, gate)}${gate ? `&step=${gate.step}` : ""}`;
115
132
  const base = publicUrl();
116
133
  return base ? `${base}${p}` : p;
117
134
  }